From 2be74b31ec79e65b114d16de513c5faa2c355371 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:33:28 -0400 Subject: [PATCH 1/2] feat: add stone-well as a second showcase piece Showcase needs more than one budget-gated prop so the category is not a singleton. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 9 +- ROADMAP.md | 1 + docs/gallery/assets/stone-well-hero.webp | Bin 0 -> 27758 bytes docs/gallery/index.html | 13 +- docs/gallery/stone-well/index.html | 1145 ++++++++++++++++++++++ showcase/gallery.json | 12 + showcase/stone-well/README.md | 73 ++ showcase/stone-well/preview.webp | Bin 0 -> 25266 bytes showcase/stone-well/stone_well.py | 821 ++++++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 2076 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/stone-well-hero.webp create mode 100644 docs/gallery/stone-well/index.html create mode 100644 showcase/stone-well/README.md create mode 100644 showcase/stone-well/preview.webp create mode 100644 showcase/stone-well/stone_well.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 14ddda8..25e0104 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -138,6 +138,7 @@ "examples/wave-displace" ], "showcase": [ - "showcase/shipping-crate" + "showcase/shipping-crate", + "showcase/stone-well" ] } diff --git a/AGENTS.md b/AGENTS.md index 12681d4..b598fbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 59 -examples, and 1 showcase piece (counts are CI-enforced against README.md +examples, and 2 showcase pieces (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; showcase conventions: `showcase/README.md`. The render look is specified diff --git a/CLAUDE.md b/CLAUDE.md index 0f6e49b..d228d8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 1 piece (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 2 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index 1fe1fbf..ab85e1b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  1 showcase piece + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  2 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 1 showcase piece** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 2 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. 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. @@ -76,8 +76,11 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md). Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall +Stone well: a round brick well with a shingled pyramid roof on posts, rope and bucket, on a dark studio floor -First piece: [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. +[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. + +[`stone-well`](showcase/stone-well/) — procedural round stone well, shingled roof, windlass, rope and bucket through the same pipeline. Falsifier `--skip-decimate` exits 9. ## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 4abb8bc..96c4064 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,6 +107,7 @@ Provider-agnostic GLB-in / engine-ready-out. This repo does not generate meshes. Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.) - ~~Showcase sibling tree + shipping-crate pilot~~ **SHIPPED** as `showcase/shipping-crate/` — budget-conformance crate composing bake, LOD, collider, Unity glTF; `--skip-decimate` exits 9 on the LOD1 ratio band; DECIMATE COLLAPSE ratios diverge on 5.2 vs 4.5/5.1 +- ~~Round stone well showcase~~ **SHIPPED** as `showcase/stone-well/` — running-bond brick well, shingled pyramid roof, windlass/rope/bucket; `--skip-decimate` exits 9 on the LOD1 ratio band - Procedural terrain or landscape showcase using Geometry Nodes scatter - Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece - Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract) diff --git a/docs/gallery/assets/stone-well-hero.webp b/docs/gallery/assets/stone-well-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..c043ede4968d5cfdbce057025369d35343a4095f GIT binary patch literal 27758 zcmV(yK&sPZ+w_bF^Y_-W}c^^9wtklgPSqzT?j22m2c#>U(v2vvObp=7M`Jc0O}p$q)UR9v4zUgs&^R?;C zdV@hxuxIBhxzFPlJkV)!?f0tx@K31HjVAZ*ruVa+bCzd1)6*%cPJqnQ7|&;nZQO|1 zZA1bByv#H@?vZt!eG;~+h$F>VKginp@rmJeb`;M{rmEjM8NAc(*jcACU!JQS%r7iv zw!J>i@b56ZqUpYA^sGLkRC<_Hl|~~0Rqe0pIJa2dI6ZHNS(sk>niT#-{$xbVNe)ln zbhzn~^G~IV(CRTct=V<>`oI2|=HHZnUM(fQROE3KkmsoM8arR6^v(GL;`WT${`4=0 zC#z*#*4%Hfg?kNP{P#{eZ+e%mv*YYj7GUEl|IEa8KgPZ$BQ`p&S_nyAguHy!Z=Unr za1W$Y|N3x0@8PHXZqR-_SzxJ{gQ+H|X6Zc%6e|?F9+ks+J?zeV4uslO-s>PrD&me# z*h8Hhz}43*dc*ZTHi4ieSfTg0e?g0!%@PJ8B$1{)-}=`Ma24T#{$)-OtJS>Df^I5T zO1g21quATUr2eT97wnk-L+ohvFG(Vz@BZ)~`C^Y+eX=41zD;Ee^O(&Tirux4MqUJH zA*Vp_+x>ZhI*8AzDs#gVB8E&;`x5T@X)v>9MH)Qh=BUwg^gB^(wGkrq>XC!Z2>UGd zjcwob+Bl|nCgN}prj=it^N52;y{p{Q-{n;cgW0LA%#wSkD=61EE&*|hKB8PUzN9Jo z8x0A{0Qf$r+L{brsz4F70PtOzin5=L-QRbjs_tQ zr(Sq9zZI46C@80|!kGarZO+C(1Al_%ito)a@7*=^q{rt@@=RRcYV) z>N{=R^;*%=C%DN!M&lw`S~$tlj%Mo}3lbj(Ku@f7KT9+I2eSOm8>V)Wa;`IcLazz0 zt(!ifDP_J5px~sJf#Df8B1Q{;tbhPm;);8N2lmJlqr*(dtK^^4X+fG!$Xn$W)W+SV zuD@1qeV%@x=?Lq^>*Yp{*+!=V8W$)q09hZI_UvBNZu9$(H|K=aP+i(x>XCzRM4SYE zQwL4?8J7ku3UM|R39}ypn+QPOE7J!1ape;z`|_U9gmN5f37qRIpOksWX3t?W`GW$Q zRiuos!&rd=bm_=70`&d3qv`Src@N2oW9J^8LpU;qQub^BVow1!%p;Sv>al@trIOP~ z$u@0VP$B@DN?#~!-$4Kvl2>f{`h19hMLa$X{W&S7NDCj9`+&)Fc{(|SnN+o~(fs&R zdjA_pUek-sXLZ@adn&KEFl88IWiJa{Id^B}E7%$<<+H$$nm)#Fhr!{y!ijg#pa>}|%JIcaGWtO2gDzTQcKqBgstEpK zcga^djx;SZa;cgbnBh^D(EEylJRjCif#UnSQt=yOLtQcaDDl>cs4%f^QKr0(DhW}h zqC7`NZXm8e-!o5zk)sk6?J4Nr-h~A7i`w8Msc&r-=E;{mBGB>T@Tyb!e@Q*;vLm@{ zLRPWWmgAZPOKNpM*zHIw1N|5(BLahARqLaHkAGQdQnzvoTL2ldhA>xyyT+P(KP7;U zY#O{sE+yXlA%+xj?0RG4_>N&D>)S74w2Yn%n`iYR=GXn6I_`ccMG=}vJ6_Tq%aI?G z*1s5Fc#d3FUqK|Z7RL`8X+!DP*2DNVq)GUiOQA$Wh^_)S-EU=H};?AN25#~ClKDM1KY5|lD$-rb+e32=!=yPABjqtp7U4z z%__If50vvPtmHEHpLO;n0@{~Vcd^-@?(3TB$aF;1%Ny<8rcUk0Q8wacRCPw>sGPDq*Z*Kx_kQ>UH0;P>TcmSKdr315{DtYAO1}=^152wa0Xlz{HvV$!w z>p&~-9PBmTGqs=$Sn;0>AGc zzFQy);n^H}(Wdj{%xzNV=%BmAe=&jZHzmuw>M9#%EX}MD#|(j+$p@KWsu-FDp>HFg zNrArLoD3NJ_DwCb&s{W@&Q9@1JVa@por}k!m>=PD#u1hCLKjy!L{5^ou~ z!Ch_pT`6C+YE9t;nyM0kK!UX{ea&YJC7ic*Gh~a}D_-MP*V4U_Cfu6rcanKUI2tRb zIb^rbC^lOCIRpjS=QPzO?&6x?)&Et~H;T^&k*~Bi(K2PQ-|7rv;0^ofRZ>Gc*QZDb zq?`NG&u$ijOH0Ai(n*5=^j2iWZ3&-jI_AJ}qcqS7P*M|Vl z3`B6+TRqqKiy4C=5!9dUYppEb@h z?5V!wWQZP8xVl`^4#$F*x|T&oB1s7$hYvEaX%*0Sg=ErQwz`xXA`&XonX1nH% zTUrd2L$j?`R&^s#RW%zygNb7x>&jL>`%K9Jm}VklnP$?-monsjG*)xxDbDqAT_!}R zATf7(I;)r@C~gnmbrm3~T|;E^4%2;l)QN1}FuBQ7gLDaG6A!E^n(FCd( zz-uiN3MV`Kth4Mj0IC3Uv+Sy7zDTjTr_)cTY($8eDKWe#MG^cqt`k*!7qEU8`0?mb zV}A`bsFzneDivvw z!g0W@e2c@;wheSVRg|#wZzWEA{Xz8l^K|`*K~bccGs8)@8e;tj_YLR%gVMJ6cs{jH zOs+TLW|>P}Y`M#0eWkv5&|30@{fm%Hv$7^ac&OHl?M}qENp8XBzcgC{c%dm;1QbZ@BxRUDj@Y3qtvJGH$SAKH7`jwsxEuq z2ri1J;UJW%{j%ha*K|Wtkk$i4G}G7bR39Nvl~hd6Ro$D4C7lbSNjQp*|IKs@`~f_u z)`JFoqj+V?;|C`fYHz+m2B-u!tqpzau^L&iGUCbbZ^_g%Kzc0PED;s!>OXLQAI}d> zh}>g61M^a%EK@LN!okI`d<_Xsj+uS2b8_cCf|uOHPc@)e%XVQVY|-7KI)T_S6{h+C zThcvn?qt0$whTG*Ah-cP6uk%$_&0n^?;aF5TP_C^ToE%MO3wq2USAx(HABpnVLgo! zhE3i7xxN@_RY$Q-K&>z9u26A|Ol>1!Ushll4~=68_HJNk`=JY>J&1bbdPmX=j(r^oDiXU#sW>^ zxH+igdCE5YREUKD6V6dhSa}8d91$hw3pRxDTX%Sv$vp8OTgXD5!>Bsk|0lG23*KZF z%f~gz!$lrZCuCeAsOygqZ%M*1kCmy&j)O0((!}qy9KeVP)Rz=dOJtght7#`7R{I2_ z7#u#WbWfs`!+Nlnnw2 zpez-R-aj^Rp*c7><)-MbNb2Dy7HrJYU=B~-P4TOl<+0P5&hjg_ z7o173&`adet`+IrBjy18R3qO0btoHZNWvj#b1(n{1nE?F0E%5U$@SQ0Q6Xwr)p|X~ zBbUEf!(^(6LvA7$miWV&>8kyPjmosRu3a>QKI`to*)$gXQJNh!qL^%4-}>8@Ztffd z*Av#|hzYWtkCIT^$Rkp4K0HYjJcOVn103Ov5l4cct@6vE{A6svABX1pzF&R(cL1g6 z%FmbQ4NTDMD2nKQ;;qeiKne<%1BT(#y>Z0${R@37i%2K5R)7Eg1?eHRo}ZXR<2OHb zcX)x{bS2T`H34#B#Jazq%4EJo3-7TVQBv`^T!qb9sFi9|*4pYvm-zQb&>t0yE$SJ}%efS_Z zzn;`+$PRy`ymijb6$m&Gh!L37AFB#XuR}Xm=95Wz|DGW3@YiW_SbQ;@)`{iaix@i< z8_S_=t3cd4bPv`TM8a$8{v~{MLOce@2TrvEy3!L$!HfC0OO`8Wd9nD$x8L&c!}^Ee z1*#2>U$piZ7&`lK4J9u9ZNHV^B}`dsis@a42O-LwR991(cG zcLzaa54t$b?k(bdTuPhlidI3+ zd=r5dN#x!iS(^L{HzEMc;?`%8IEJSGLBA$o2(e0&3ywAleGw6m=7WaYvng`sC6z{Z zARO}&8MFXi0n$@GQNG%w?y4k=^uRn?IfOp*Qn1}@Tqzf)eT-=zNTLoPIdV+jrnl@Y z=l}G#tMeprG=oWdMg`5n*9K&-+47fxAJUEK{EOchF%R??cguA#aHyp9dM1h(hef8} zHcL#hiIwgWT>_efY!pZhR1vdM2Tf4?fr?y^T)Er~-^7<1+1dq6M2>^eJz`nC!~Qp% zWneHwv&h%2fZuKS(aVJDrQuIQB*%JbL%AmptNpE*1pK>+ba3uqn zX7e;56rWILiS}FL+hI{zWS(;B>14wF@BgRFc-83rx(VYZ*j;5fs(+H@Xsx$fWFSSb zSQ@0Vk%`umEFm?GC@`aa1M$8CrXxjQteSQ!J-u{BEv(L)9&S7y5BB0BQ7b1GNk|d? z+H{{VcVi~3Wcu%4coMcumELc<~@&KFWj_HM4a-zt7PvZYSTA*ZoQdV)1cqHQU*fbJk>57GZ z(d$YR; zFCYnZ(vmzGm$Fz_YyC|n#7(Z|O*>@$FI;76wm#EXRc=~OnNUFOg=k{x%*2Shp3q=E zmKYMbksT+Y-I4p{2D4M>;ikR!8?=wM5BM0&OY6avO=j`it_;QK+%dJ%ul87+UK`b#9I=Z0ZS>F2uY%dXB5IvOlm`T z6K|Hxy|m7oF$!T6KlK{*kpRiJ-SieowJZnt>dY6eXav!2bA;oLEH58F{)7hRmXMsx zn5h9|O;_6O4tPUJttv%Tz(s$y=p0agf z=pI9!Xcak@xwcWnZ?KggYm7=T4{6PiS3>=1n1ff;az{|e*=>#=>H~wQotF~v^OJ}B z4s&iblkM{(JV7qvcS+sUM)k`yY?{p>icdYjHpg$kpx&@N^AtQ~9X3x+ocGlf-~|YK zChRdE<*b$8088$?xc{X>nQ+&%gE&)sVqqQLM|l-UcrN`-fnQntx(Z(Ys#B2$BB zxw&!mHF+HY{|)NV1A8qe9f&TM21cVG9?g_j4&43FJ@*M4mVj}75+*^oK;!(s0g>V*uD z;Jy{cPyqh;BhQ{LJX!FrU@^XCJYLPHEsayTf8LE{e-;xEc6n9S(VN6B0J0K*KqGMt z=`e|;<+I>a$(X<|zI+ZLUa{<}c7wR+8pCJc&T`?Hd!BZRooHn9wG{A10^j`x8)R%< zFKa6&GW2uM&LjVSI&IJ+!DEZoL zkOJ-r?PxN242mqqDgnQaWLnpwhR_tI+m5UG2F}k?i8UGKveSi99Q8}~6I{kDqv8X5 zzmx#<1*Z)-@_3yLZ2&E2=-`?G5WZNfcU;gsDDzZ{@%1v9C>c^Bsp@CzPtdi}V9Gi+ zQr6QCY#V#6g+g-gC$aURCd99sX4u|=n%-CS=7iP>`Rl!acRdmRfMV%ohSstV3M&Zt zLKS6OM;|aC$Bsx@19|kAt#K{tFwECm4Iq>s19zYl_x-N*h5RvH;zX|-wz9gd%n3oH z8iI1MiJ$;nZt8*Rs18Sax%sbvwe2QnlDRNmOLmHcAfwgCe}(3txYDVA^p zl$4g?C=kVr4_k!bAN*p=2p{(x-uhQY!=#WXbp3?2FTICcZ5gq%gic?7>h8e+0BMjG z5OgZQl}=EOuFS8r@qtiVPK zTzSXp6{X2@NJrLImSfyNI@8bNZhTLB>Pn_e1)6Om+xf1Hf0FOL#fWIF+L&MM1x9gX z9>`L<1Y@;Eb$?HJn5tSvA57e*i4^A;{y6$C=x;3=j=HbPo>=$4?QLf`-Sc2KAX7R_ zq`+>nqXY^aA5)zg9A$pYp!+47Dj6^ zh;dNHk8>3Q!?X83d|mk-_eRb}wS}|@#Pcg@R6?`1dw`o55JYGbhlcEGv<(pk3J<>6 zQbI-k<6pLo;4Vjz>GT%-fVnTms1ibu4ldcA&Ob)QNUIc*CrgVYO2Q35n=y}gI%Z$M z@%*dP%Z*$@E&7^DdCmpn^_v(3Z6o>zY)C#n!uwO0XZ#DJ+K3%u>FiBr~p zs8&^>c7aDcfY*qmtPX6{`{S9zS*G?*IiC|9R)bQ(`@>0rhM9tss%5q;&DN%|O$oIp z`O8IH=J#O8D3rPJI9dSO9T$b!dEcLMsgN^y-~Vy|eQycz#7IDwJFJ22zgEbUd`s;~ zXIcC{z7Vq212sTfSri+9ANIuXE2>*Aj91KJtz-S+!#SjMOSmyY>m1Yw9G2O59Up4e z`nMf#7m}*B*;e9X{+%u49!P=8A0wSU1tea805AB36yN1132Z5uG>T#NMIa5dDWIpa zqy}6ygYd5Koc30od8ESbDzWx27cqe4|2OLl6naq?FU0M5`550LF7^Dbk28Mc? zENnxu$K%EFGw9FbA15)Yxv?5y0R^jNK6SneA6}@>}HQGflKjWTort(3L4Bdjg zRF?5YtJG=QK=xqNt?HgCODAQxVNl)d|L|(3y~XWQ0k8ZPjM&a=AktOs5dL7g87MB< zq~-;v3X?e^h;$aqHD7xt8`*xgr@h7MbywTj_l}JSZC4v=SB23oXOD(N{}(qY4NbcF zSO_2~21PD;shI;4>aKfa6?SC>jq~_3cl7!1=5ZYq zmruhJqvVGXXCkJVe~hB*E_m$mg7&oz-6K)q*bq;KgV(tfR}Af^!%gSrUlX$OYyu=; zsn9fE0tpdKnnDx>_uWK=Pq#OXTNSqhU^aJVee4Rk=Q6d|#|#DkXdPsITy+)#yjjCv zHN97ZV>h* zt|u|pdgescgiNY6hTJLo(ZN($FFWpq$AYE>{wpka+-nfPy@HT~6#;8}+YId^$8Spd zfH4j4n-b@*%%0X`%&p7xuWKX;Ui8a&04Bp&m^iBK(#f`k02j=j(6E?3HPXDn>`e4S z$7$(w$Se+WMbqoY+@;P3fN1~so418C=wq^)F*3zNDylVDDe%{7e}t#%^wEkiCD8BE zG5rKz3J#Kn-ejz(ew0S*Iw7>>WERM4b>IU8pU?rtrCl?^7w&v{!~JUBe~i)-K2L9O zJhdm#+%5GbvSrY8GS(_KEKSv&+MbLjRP5oqHOe`t0LB{N08*ppIYax{Gv4BU_Xh2)0i0x%!$%Q z0}Bq}XpFvmn^12_9M$@uoNQriiwV3S+oD>}o-AYYYt5E%+3F{plegE9zqHHT3MQi> zK^i5%*HR69?G6dRTVTS~ZsEz_&VRbLvA%A+SXpU1*l4Mx|*=8GpN zxDU<9wB*9~RszONqNA3k?g}}=x96R1z02D)8AU@2cDvD2n6uBNR${5nd!7;Csko_c zY7V`LmN~A{!&do|UM*0`l~0J<9x!=PUj)17yB9|nCjgx4C^DfLG%UJg`sDoDzXbUO z!Os254OQ1Y_L4)KZXHU`oeKTKv3oJYG@}2=v{d>t_{YuF?dEhhK-QXil_eGwTlla5 z%Y|Tq_=j7onU;r6gC3bNXo3hAN&bo7>2OpbGB0qoii0cDIM>+-6Tr7v8aqVlS~YO6 zf~c&k!HSj!1_+T3i1nbrbq3BG>*2uug+v?~RAfi#h+zr9d^^}FU)EBwXZIzW(aPdG z(vBX2UNQUS9sY~ZZk0J;B%??~lW%oOffEbKJkC9na_G*Hv{{G0U9sbHB-t@5^Wa~? zEv(x5Kp5lJArNMz9{^X9JTa?+$jTtG`LnNR|8t^C#}@;u;suJcLp}(=bq#37m>DWZ zz{6`*EMy(X`Uzn9d}WAA04|YwaEEW7pc+G%M`B~NYwtNdI-{pwxc41!x;ZqmBHZa{T#7>s#Dvj8SkPT76{F4j}`Sn>=; zJzXiHv3iS}9JqoOb%R@bsq&nVOR~{zEA0C6)e=8<6;)T=`6XgYby`vvu+q1j{F}Fng>-fhT zP)ozLSkr zRAh6>`Bb)B_~R)>NW7E1j%wH?Qb>y=jdfhCcM|)A8vhYk7sW<-@3za)MgrF7xT9>7 z-$FYG&+9ZF`aS}Cs<&N={eaVi8(g+A2{#R%RiOoekwlvegj0+NYeG2@f2pWShPxRf z_q^JR6f;_}=Ugckdq<)CI^L<@~7jXdae4+-(exljd#X(iT&yVWoXA~K07x0{uTh(PU ze^@?f$XN-jE7iNl+ifcxewohkPafxov8N&K>aU(B)+eZ2G8|k!U}J=cpV`ahA%)LJq-AG5b1exMf_qG>e$tmu#zOx-n>t3*S3y@s!sXAo0CR=`?j%9 z8Q}-3ZhOjVq<`^_ot@EXHerps-a7uG6nsihDXXKU23D&)IxcS0;;BAOkomq7o>~Wo zwrbbRxM2!f$VxyZ_k>B5I#D`T>^%@?U>CI7KiACYcUS06m#69~uGX>zFU^%_8ieX8 zh2FGQR{1;xIZS)T*D%jW*o?#hvMr||0(P3^0q-oym=a12Z~z0LVN;2z&?n*;d~=Xk zchNrv=p+uH;1HbJCmxK7cQHvl_g51R+tD|^R&MYGa5`9#4B$4RHj!ed8G^lzM-bOc z#^7*t+0|1uPhxjXmNiV%{1ImT)bqr97C?ISfyO`eMcyJ$yMu^BFu=|t@C|8LS-x1K z6vTlNG3F#yjl$${dYe{ZWiS+n9_%*#L(CeR-oLmVLr=PvA~w;)KrA zlicu_K>GSLyavFEx>ydf%eHZI zqSs$DT8;caeoxoFl)bTqRgJ7MhfIdFwB~WXzz_h>Kug;^fHu^wYAg}{qJ!Nfu5E{u@0?ud+Aq({^)`RGc=*S2=)xI^eH_bWV zlIlS2&<9j{%CF95_w3L!kDhD#3PEaeCOIJv1X*;*_brnW8oKvTR>vm;o1_WAE{}H( z-?nq*;MCc0-~>)fYIYOa?M+n%qzdId8~zz>AOEPlTi~!Y+o?Ss#xX@=U;hr2zPY6< zbYy%`bu3_Q|BGGzzB~!H@(azoCA#oJR2Nkgt&2o%-Fr)J7U&1z<6IS*w*Sz)jJ!OP zrM39JZ~AZCegWn*aeZeBR;zgy&O!`k-y8UEA;Q%yUW9?_fiFibB){Xbi`5n1GEWOL z#-(JOFdu{rCY}MDm%`9Ve0J6QSc*$diN1h@IBQq$f4fi5D^YUt3b8rWR?Z&JR?Q(# zQ}QzM|F=^6Wkjfkeb@8pT>2cYx_f0E zMRZ7AogvR_nUnmfZoqgJsyS!1`lu!5$X;tBZy^+6un_p01gW*<}`IuS3&QsaNcijt-)zQS$t zf{|BEcba!3YpUKL3UDhU*&9mf*;|6W#;sP2CaG&#NOszOD-365Zsj(Zyt=~6g|LNu z@=fHkKie#4Gu`KNLH^hrC{f|J(5Zyae`h*Ct4uoG+R&iA>}U3~;(W6RmD_c40d2}5rfDR6Xe zmSCQBth6zvP#H7vxo^RU&kW)$kq*!ClQzEpYaki_LM*q^J?53?&v{~~%Rg7d&uOM`QlftwPV z^8j%wM`10dCOJKPq=4Em3vY#LqT>exo0_=tLxIMbfA!X1MC1AX^a$qRkY4%f$*&Et z>E=JD+@lU&^$7^IfE=amh>Rf?vYaQND*6tJi!^qk11 zizhBVAtw|rKFw|z-{vw1Qoi<3g5AT9ia(txdHfW9RcUD?zpkI)Kn)K*hLHGAlRe-x zCN`*3l#R>66<_ocv=m^gh4;VxQsn(df)sdZLi>$^Ic=PxT!Q(Tz8;i57EkQlWUTlT znG{;F)P933yUSP4Wd|$-WV}z(3IvVvV!wJtDHR^(pyCIcWd}X$H7I?%$MNk!*={_O zPo(cMxiP!rX-H=HQcWfFZetIVC|c+yiB^pydk@xwpn5veKM!W1v&8AVvc~8(M8Rh# zT0J0*)(J1YRZ=wobWkn{gjkPbjbHIC^RK$Pf5UBfj0E+3#Aij0X(HocLHnsaEuG*M zM1;LY_A%ySW3FFZw)$LwP{{7%?v{*SPMhM{lz2zfGmuB}{E?6YtAlzeDSbZzv7lv;0Ri1_=pML9_SoO zc27h4<&%;1Op+M}*`Y^hOdqy=t93H0XI8Fni*o@wqxq*r=B$7S9S`Tbk+>Q63!fq4 zr%16cHT1gV9g~^w4(uFwFuZ#$r!XUAL}M~`AXqW!!2$xUH`n~@U>lbh0{FSH7T3bL zRx%rA^cDbYn7g^thbtGl8(}JPH(8aL?W$1R8u8JkL}8(j1nZ@Cyn%a) zo{ou{l5tuG!=)vzu`ehDm;pDgF37QF^sche81~@TRNUXtmotA(-nVDD&H|7u+1r=$ds1a6TZ3>`IgH-DokZ zR{8587=G5tGpY_5is=q)c=X=ZT7{6k5imGU&#b(LHK~kOHtVOVbOM@zd8M({MW*46HB%{CviKTPkzL_)st9|-8wzV2P&GMuBAYn z2Lh32&m_);ELK*7Nh$!TR*tC=loKUj92}t1ND0%75!ViexL`Df+Lr4i$9fW~0=j1NA~2M`Xj|NGVT#}vbY1Ix#DOZMEV zJf6w4Vwe3X$zfO8jmPR?8k$aJOSxxnlCy8(fhAa>Nn)l=@}cwz(2-ctkf2j?IakEw ziOgK|&l^m(miGMMeUV$yacRlNTeMrBz0I^{NC@rRNdkm+*N?K@XtVV%eYCnONkZ6% z(c*M-TV7JY5ue;Acc}yV6^Q|Za!O@X7ueV8qW>_h{-ZbB;Lr%NG(X1h+)rFiKp1Z| z<0WCb<|nJv3r+9;wILFm6SZ<%Id9)wo9mxBd$Kr{DrFuZ{3g$V zV%(fMfHCN6CWj~?oZFuM4pmzofNs-ssP)e~bFl@l+i0y)g$Y68UiulRsO|}tgB0hKyHq>c+etVZi|$IcdRTfuTif7dR37p=K; z%BT||lon~mua6e|Cm*HlML9=S{e9>jO_TsC5V!I|4-P(?0vn^HF&IP1=T50JiWES7 zL}f|}Y`E;Tc3Bzj*8HJk8K8K4=k|cxAyEt3QvVi_9>7o2Il#%2@c^Y(f7^ZtP|xcg z(ADyL!g-~*i|m(jxcc~5Zc%`qaQV0lqG=4kJ{25m|I(m(&_ksrU2SFWB$yG5OVO&M zztx03?2q@cxOBp3P(I?XX(nayT13Q-k6t3fMnU z?3g(;xT-$N+B*xLn*e)iI8>Q z`o8gyscC$TOJSlsc!D(zoAKEwq!usmQMt6$URr0&={g(IAgem<9DN4q%^p7Set<)O z9=UvKLt@|)khCTh#V5bjd!urEbEk0Ad_<(T)@Yr>b_dU`H1Kv|zny|qx5+HDP0=*} z>Bswlun(a_b`&kDxYxP*a;t&;RbQf~8&%&WCKCu(l}z}u{u1dENJR4+CkIplwELNW zJz`oKP(%rts5dwEm$T?^)rkiVP!T9r(N zO5kfF&E_*WrTiEPIhK-Ye#s#U=CJ)s1W}s85dVJFOq`)Y-H(8o#tPsO_52<@1H3< z)^+kjDA7BO6#1G^@QrsRZQSg4IaIA)bsqldZF|sv5n|}E);a4CX#$SkW|r~j-qj;cPr0n6H2&pMjLcu2mg z-*fV#i)Lt&pV@Ct-X?})n0&bSIDkz?0V1(+NP$=65w>8CT6dQjz_u3|SgVuv3jJ4v zQ1vWWW0{>$_66+c63%D@S38_7r%2_$6%b8Cd~VpJ$N5}7pbSy*Uwnbm-y94q_jJuF zUnh#rMaGFsKf}ZsL2bdwL%~RAQN=3~D-}KBCO$9;BN6@j3~J9Iv1M;zuNF6s>~RI@ ztd2}u=PB1=8AJQ4Ek^4CUQdps?c~Kx0eH-_n!H~QtWxg8lbQ&quvW7XJcW}0Cvt{}*Es?LmFZmNaX&K=w#Y#tMOMgC*vg1GI3zQ5C zkrJla=+@|G39-r`A?}qWB2n3&w_zYQGW!E^Vtnmn12riA4PaT6f?Oo1kU|Oez`Gl3 zj;sQTY|3-zlfwkxljUc;Xx!8|!~Gjwb)lZ1uoUTDn~hI;1c7H z?0phr+6GVzey)Vf{dtY|vOZ4<%V2)#b#!?z_OCNSeEV*kf1jaPfX z6|`HNhs&+k!BD0l(>Vge2-({Svhw5{5rFkY9HZB9osMEn2cr3W@44u@zNyR!!bp3p z?|#8V4_t)9Nebs#30!$(Eve`P{sx>buH{GQpK!W*h5B)w{{~vt4%M`%H3Pd%*$iTy zYUvD5XN$=7(G-4RWn(@?M2%-oDY?-s6^NAS@6OUCyUm4t7umX8Ho0-*Hhj^d#(510 zIx@h4+mKPDx0b7(Cr)xr^DS{GxYGR_H&5*sG}kfeB+3aEgJxx@K|ev9jpOD+YI%J_n6N<{x`dHZX2Pa;>9;U#IB!I? zcXD01FM0?eAkSemJ+t`~Viy<$E`HcPD{L$i1%>BT1@xFmuof`|dlCT+A>oq7*r;)_ z6mkk8VdEFC%`dAIt`0MS?1KR$d~_l}9!v`o9mKN^H`&3&V2giIQk%1BfqiRO#n%E8 z&Z5byVAD%%>!3k~<3^??xE zW6d0^)1zK1su^$DyYA4yv_Flcq$8Y6C*VW?WH@=mm;zL_A$3n&O#G_p3IW}{UR@A%+&LMdXwYXX-oO$XsVck;lV&?6aS7Hy*5g^(Jk zv%HPRg>COhi#54}Ollz}LH+0a*@Ls;%fJbr%7!%IDM2 zKiEEcb5pdds;9+(kIg354e~DPsCepdb>u+V*$e`zQ7-;0s61qLK!Jb*f_7v#lW@E6 z0LubzecR}_^g<^LnrFae*Ku%r8KMi(Qi(DV>LSrL2x0$o?dsT&B%|Az10B*2QS;c0|F zS>8LW_gzS>|H`8tRbQ2T}4^e&!PL{?AA70;V47cB9@J;jD8x_VM(Up^%+}vle!%+14TQ1@QD(phN zL8y9b3)fK~;o)pual!x5J2bPnbGyH(-WSQTD6%aeksbgubFa{HN=jys6iRAs17M`> zddFV5B2fXYCNYKrMl3q#DkPF<@)H7|j6?`~cn1QoQjCz}R@E5)!3ZJ}N4g|7XBkHY zh$IStepDT+L@`=yUi6NluMdeT%1sHvJ3V6NC0meSCXmrVqKT<*6zJ5drsFt$W@83L z0b;C~-{qR-D|&vm!~y;A_g_+>AI4`2%Bo{I?3S#N43eNg+xrqql#M^+a!UH|WnyIg zKR9!fic~@)nF_m6=8D)M6L}4`-D>y5I;&`3-YE@q>i-A#ZJPd;Fduf+5DN}hKvs$+ zk`o+M87p!+by(AIZe11&}TBEnP)#sPZ)74MT_ZB%mcpU1$Jzwu6q^HlhdZJ z!E~tJ_J5F^p6#fA^|{#KUYNronh>O?JzY7@SMMw-5l(`pg53;rULx$76{7SCFI`Wg zs5hQS$vTgX-~15UJClE*v)ETcbe&6wa5!gk=@%>x6G(X|143RSxe8eUw9vEHYsVyj zJ#HMi%>pF)Gg!{WADbiHFM2pLSn>$QRuHee8*VQWRbk2WvPER*(tnUh14bsBpN^xN zT@??A0$eJ`JLa*9$xQDZW%*UDo=bCXTYC83=THnX_oVMq9$Ce+6^FbOW?+)%Rb`q; zD4fg9XT-z|nuE1-&ij6os#(;~75m{n0)W+R@vS~Qfr_-EXd4EFlEN<~xNX!xHoLG0 zFE@)g#YO&5kfkzBHx*yv@3r^31%_5uB9oTG=Jo;?$`k3$C~Q7fsf}(`6V!}e;Vqct zE%Tb`S!Ac{$1&T-19gPq36>Q^31Q}=bG&h8S|)_bG>C|bO!RB2%enA)0xb(fzDEd7 z>L`W`RC29fPqrnaEmRefP1>3LT>9#V7h*ys7a0y*85grFDh8-mFXsHTfi?Im)SyHN zW7k-GPU3@kJ_Sw^?LC(-k@Z}czp&Fdhg113*~GCA_)g~{7JuVxN+Eh@f@eMCaXX(0 zL26nXjc4hH*mGG}bFUcuknlX{y6KnGmdCoI~6qu5emQp$lUK+|UgnMSNH=ZLw8 ztv-n#GU*CLH@a?(B!lt-JP~oeVV>TEqXoU{n21`m^@*46)88_Hjkw6J$J54I|?}!vy4H{UkS;@uAKqMc) zLMTY4cufn625?J)mOW9qE*))GM`i-=A1U&|G0nh9v7Y|{RxGL0m~MAVqE#Kq&({cm zR2>uC4|rNWpPpg zcC9={a>x>anS|BX5O5#~;`6ZX9QHOO(L{+VU#|-E$4v#+O)H(6<9%lS8S6z=s>%sj z_Tsw*l&nqn8e+IKoGGTrf(hF~1e)*pmIe2rdPU}PQQJ>r4=RtqvK^YD1D(K2vyln| zw=+Z?$^cc1pGxSNv!9s}Y{UZa)`aLW@`wqz#U%<9>SwCw+ zcAr1@doT9pr9e~utdtBbb4mh&(YLzF+88atD%h`iNAE>aR9orNO~PA_Tk)nH=e?1c zlB@HyZmW38n^Hl(zYIXQu)0n0ROihFZ=yH<;D^E$K$({}+`fc6M?qU&ux2k^(G0r< zEBrca!JRJ19tI4n$wRoYw^|Y0$=21inAn*suyc&VIH}5*5A56~wAp%@mgx9tr(t>I z5vlojo=6hjYSSTWP1>YiFp|}OGKuvawbJb>hw0$2Ui|cI=0tAU^bsz~okHj19>X`w<1M+Ew@KI+a&ElgnXA z-H>e{AQNV0!y*zk;H z@e!zx6AZVxOASL;?;1+@8C_FqRHIpVvtGN;)nR=kTdj$K_oHW&t5E^YGGnq@cBC@z zXh$1OfeNlLTggC4K*~>f+eNzJo_6?!N}AcN((e7m$zO*!U$c#+(e5UniqlWkpq$Jb zF3Pm|kR%QbpPeOqnP{FyJ1VZ`njB`~$<0aws@G%=BpAhc6QAVde|&kZ%0FgAqE^ClAQR-^h6EdVX;2e*C# zjr4gNqhQy?sHjfC&Rg3d`v8QBAo8iO0}u@LW|Yg|rPmZQG@8A(*xW9Rbj3r;>s>KdBdVJhxdNd6Dpv1LE`?}c z9Az$66B>$OPatC0 zBvz34fw6CrI%#1UBJ)y`t}fCEV_YXM&C2h~>>Lw~nE1B?H6nQ>Sr6iF@Uv&Bk6>=) z(OpPZPN^slSyV!j;x7u%ObTo}pbcm)3J(kjvfAegkBr%lL`}S-iJL)bJ$#qu>_;p# zsS*?00p%36oBMQza_s=gWh&}br=h3WIWu1F%q#j>cjVLxX&Z2Ei-hyMW_BnZqgvd{ zr&I)cr)OJDUv-;!^wVYyzxP2&$sG@ciRO@QGwwGF1*^cW@Ex-|9pOGS7B zK%cTNCRKy^%|*#hVNrVoj}ltnr2ZrD;EgzyeTJqt2w8PdSx_2CY|EKbc-42ms?$v0sk$XB@T8qk0BW-hY=-HW-+WS|;vl%q6D>|)UY`Wqm_s|6 zfLlR-vvoF&i&p^~03N5;=NbRA0EmY0k*bgYjZm}&EQyE9I&Nt~v0?012=3&S0!SnOzyz-bZc2^9k3{GpL!t9`c?CF4!MMJm8@W z!b?IdAy4`@*d~sqe>gW0#9%Wa30bj1jkVlxrTVRqWp6^(%}zBob-2CVF@QzKssY<| z3a$^E0&JFn&I2vFqi{%rYsL05%;goc8?NHh&Li!?LV1E5inKgM`~mSor-M)$tzv;|_3Z~A8e`IPEd0Emlf;Tbj!VoG zb=Et%uE1Gmt(kOTr5wOQg0?0C63$7rvHRH9dGJ67YQW`i>vs{`Ey$udv1BX)Habd% zWrY?H0NDkb-JqGi8s2a#@P)~txW~q6kP~R7W12Z4hA6`5Ia_()g3l0Oyr>2ea|djU_jGz8?&3aMvdz%N7a++{^VL|A%Ox0NFGZRgNGPSX>Taf^cZXNpz{(Kf`m~PzcAviZO$0@;zw{ zF*hMqX5$lBD*#QKR$u(V6pa}YE0pD^56K5ywlLkVd*ATf03!bR3~^ggfQv}OgNu6h?n{DwloK7CI>LDc z1L*3c2&-ACqQ6&8AF(opO-SrAHkls_akn6Dd{O2{^#8&oMZPd7t%FOB*ab|5GJyep zuwUmNQN`cnz}wg_n6gR#O8)6Mym^^6maDV1SMT{W6AO3Y@7F-Pi$dlSr%McieNM}P zPKpXfTwgbBHv?=-exrq%2}FVLuV_P?c0x%I}$Xc8E(W_;+Dtgdgt#3 zhA5?X6XX$MIS)Io%k~1zn(e>QcaZ5mLx)9?Pkyk0yyn7GULvxz&%y&YzaL?{`X|Z- zOP{TFjL$;#Vp?P(ydDt)XlrK%&W`{@O>X{^XhxSh^Ha)=59=2>ccP~KM#FYL^;-`1 z;UONP{e;m?2@)HBQ6bMWR;aAI-9fROEhS@0;hQT&0L7#OE(Hl&+$O2_8P2HF1FkP) zSdpf3om+jLB&p@7{}ebb|yRz&o%wr`M2c z>oS{+vxWC7-jIQzocw!vK_-)v_mGXzJS=)R8TiKe3;;(29_ABOSBsf}zh75cJ)Jn$ zMZDR2*3|WTRU6af0ZrRvw6+aPbF@-Zr4~#q;y%B!EnLuQJg!cn56N-gID2FCXPmDW zkNQ9FCHj}^F(hgdBKC1%UTO;5NRQf)=cAKgZxxCGXHusSTgqJ%{4j1GIrkGf=pk2e z;1zbLwQ5)v_+Y&cw>zdER2j1XJ|OvJVcXTFA(1Y`jdbqbKbrD!$dz7jD>Y5{4ZDDz z)kHu!Cu19HFTs3BpFy8)33-rvd1v)ue7$dns2_is?uoR$m$ikr?HO8y5WE|@CwJRK zHF*66CMQ$SSF`4e46tv}=9Ae&Vuu%qQ5ZBfb*b4{8ZWkBl~<9XjsvsmvQEj=1yCz+ zmI@rZQKnVs5;F-2l+@E%F@7{#nh*(}1DMv=X`SAT)|nNE5^6e*<6#T;vH^%4#DUhCik5MkcvlKJNqoZUM0n;dO zW?N}knTYjwS=$V&%6;u7E7}VH1Y{;106Rn;Ejm95jW0*)A$zUuYRA91Y(G1qtCRsR z0vO0^p2{hYc^RY?$j3)v5>D%d1iQA<{m!JHi0^4BDSO#DRRD-d&Xhi0P^?6j+lVCr z77MH3f%3d32lji9H{<~&h~eb{JIjO^1`I2q5*o*Dby3S{+)=WxBH)pb+V95C1KbREt>9?t^H(JUhQ>bhWwk5 zEF2*kHwrB17ynP4h0EP0Jp$=m4*y9qvWa%_BY^1=w-Ny6xd6!c#8-@C>pgR6(Kdp9 zZ8g}9vXoQ+DK#FyhcRVlZHhes(A8X`eS2X7zwN3S)V z;Bqe3vG>wGrQ>cg3HeFzkelY6 zHvOA>7+{1;cid9wu24=~As4NXPr7o@o2{0?e`hK?ws5#D&Gs6k-d~G+&OqZm8 z*+Jo))$E_X5LaOAaL+0Lwds>n*q@#B6O1`2)qpo4Sgdz>JH#HbOc{oVL)hKZljv5O z&u+QbfkD$V;oPQwmlNL~Z{8K6KvP92wailKXy3(zd{=~Z7G4gp@p?n5=$usP`J}zQ z9K+(ME=i>gm5a3nHeX70xm(OJ+bQ%Lpk?_;V$}Wvm9gdhXlS;6;J^={9a^<+(^xN= z_*t^_Omv)kf@#ooDm9BVFobYMg~&@I(V2|pSn<0zQO$CIawv5l4qD{FbEfuJ6b3yT z7^LihrqU9CKuysMVS}7@4Q>^mzMTPa!|38QiszFwPKkwX_okQCE8Ls#cA-4@i*a%% z9G#UDOwh#9CCU@xv8IJhb4#=i#HI`S53?gy&O?P?egW?BRLayrBC&4F@O#vXO znF#6dZtT-^xx65V7Y8O5D%mZPUz?8!@z)(psv6fq65r=gsadoO=E_B7_oHKNc7ZV4 zQAC_mN|JgI(=%660i#<0t^SY^Yi{_^k8o4=*!G&w{6tNn^I%3E^8qsw#>g2eGh>Xb z4b_G=Up^AG@y-YNQyt9JnZkwD!5^otW`dP919FfHICJ~hEHT$E26?;LQ(@Luvx1K( zu+Xu1g8Pu$uUeuzZ+3j-iNAvcry*h4(BfGkW9*pY?4mzXnzUYJ(`MO83dRN?Vj3Q$ zp<83C+f%rHHIxU@f_PCKh+(31xphXt^0G4~e=xXJd*6E}iq{L2EDX@Y76YLbrvTBm z)xX~Hrf1D}$Y-A~KBU+74pQv3!JYKtB~=7E1h4TH`tG57H#TOM@0gt^wAL9${l@#$ z6f5J3Fo8UY)^qbv7J)*8^BiWDXuDn|$qA;kF>cI|pXS$@S2Z0Yw$4kQ-P+sF2oV(4 z&Vpg0s2>e^^jy$#IR|#2dEoM%_%fXxQeb|vvy-r_qET$lp!C&sO`h1f>#Fo)^f<9v zb$H9Us%oFO|BvQ&YwVp8b}~K*7(g1-fGQ#rt{}Y~Q3*h>>ee2{b;>1`sr1^$g1=93 zRqYtz>(;AgzEhXbuP+SGSCv$)#;r6)5;+D!17>wmxQu0&qFOuCL+lvX&2z#=1^E@a z+?}j(xvhd@Ox~U8M1gSUm;p%NX`4+KY9o_;a)^lRdWy<4PYQwbbCj#<$_#B{=|a7& zrHPHN38}(w}=@1d(Ii|eM*{z)V{j4xF+y5 zMtfw7{Cj~Lg6ocHqpaF<91+Jlab(w=>u6x>Y!$6Kar_j#ID29sa{p7OT&gG}{ z34uCB#U~!w!yiDa(@jc=E6fBxy|dkbwX5Fd1%c|~>fS5ww~ME1MG|&==jZ>u&A`8D z)YaoUU97*W(H)ChRQ1{|bx>Tqc79ccrslfO)mxsQH`?ab&(_%{4*ewDj*RAwU3(GzXE?w8D=H zzcg@I7>Rsi@u@O|0zdSYffC;o2C(_&e0#i%l9X#`phw>1QpTmhF5~f{$s1ZhHO304 zS|9AakHYT#_>2}R86A@hzABs%#1~D(DaSQ&=l{0eNn8HsQ`_^`s2O3)Hwk;5wejvR z=pIjw926GpE$RJ(qT^RX&XzX7eW^2yNptV zk=J&zqfMGxVrd8ky9?W40ne?ti5SnZEA1YX;LxrFARSo#q3E~n>()uEx1kpPZ@(DXTPb+iMr%RC=$KZ-oRJU7;PdOYE z;zyS$jE}J1jHF9MM!9{^4TL&GE@8k;M+A91p)JUgSUg`fzXIt~`AXCCGIMZKYh^= zX8RQyvny?P)<_IL*ERZ)P^5*Y2!4}#;qzzn37BtMZVxF7LKAh1f?DkQhpx$?Om&mw z8|~ko>Vb-?a*r;z8pfJjBT5mwo+F!Fw(c;jol~zQrgVY&){Xb+qx1MBy_2!a7_UyW z%$_Mk<&m=g>w4ACAw&z78ZrhviMAPsjhqoO+7jTRm_TnO(W_wd!x+geBGA_0(ZTyp z=y|8|_Jetyw!1fg@byhGcNeB_T)#qYSjStIGNake?H z^e5%G_)B~oCF3y<9}C+mR;CIt0Z?LNfx>8g0}4zi1ox8Xt;FC|;=zbZQyCM5p7q_W zSRTD&l>G8I>s-tNF$k~!-K2~4(7fcB`JlkHD~RAgB~(XjJqPGVucs+=rTcv4fag#U zvvJZ)@BiS3H>PtxwDz z4C+p=dT5FA?HG2fAT$bGsn+-N9y`3b!hY8PnE7H%?s}8jr{s0ziRg~P1 zH=YwT4elC}@O<@VO!l}W+yp{Xz<2W1sY<4+cE>k~ZZ-qBBQ1t-)jFQ)n!pl5h&l#R z;g^}?`40-BfgX}mb7a3Al}yZUE{(+}o766UFW&b9m7S#rTi>X5Xzqo-@iJ#Vj|9Do zNTM^-S0f9`)<*~JdHx&b@)+(9C`A}HaE#D=MXRb zPc6A(S=VJXc(0G47IaQr6_x$jorSeCmhzA@;e$ggW3MTlxQ;rGNFr9S%5RUS7;=OU zP$XVLiSKVPr2ZMc=jky^(Pi1}w?8H2-`K-U@+~hmICC3n4Tn5)y(X;Dwumd3cS%?;5y=5yYmBd#s$6*MF-pH~w3 zhtw^K&kAMi^7ro}$X8>~pxnsyZdp)}x)|U+W-&g)-{HcwDCuU)_*b!`Q1>To5H~-z z)>~;m&f#5nSLcSff!4!(`x09ct7ZfH*XG>CdZ<;%h8}0%C9fCEhGUtJ*UJ;x4fq;6 z6hcz#z^RPMeUymlm>YP9$g{Ed{c-%a3ubp{k9#&Vrg1`sOaEN<3<5$uZY){JF{e>4 z*o7v-KxBjR{09~I^Y1y<9I|H;>o}?Lca4&Ri(qM*3OFn?rj)H4N~IG)zt6nt1A*EA zrs-@niRR8)3kZg+Tsm-2t@IdbwHhLfWG5Qwj-->5@3TRM0+pTgkdr&GNdkZXFo%!< zZ}mqy!MVZ}}#4gUBKkhrB1SKt$RYjIm@7e59thgM24kQq$*4KBQ{mBjRB!+yy`m$d+<${74%JumG;Pu;0fqX4H?XNfu(x!l!thW*LIz}HhJg-;chr%Z;AolvUf1_0>id15-Hg?M0~`PV3@0EnovJ@NnRXhEUn|5cHCEEi@!QPRdO}(^dn2LU zbJxbyM7ss|EwjflUnJq5sHt!1Eue9LPJ8aILcuCx%nn__1k^6prkKo}+-qfz_f)-O zMQ}U^L%)*1S1hheRp@c%6aHe>1UM;Z(0nvZy`4V-yQ|Xws@Y{_CM>XD)qFC#o0pXL zxD)TaQS6A}`sx-{fX{K>-3Im+_d#n+&`Z&lnY_f?wBn7!bC{ZvBlQgJQ32*pmC|6z zaBzt*c6w*-EgHeJ7!$`rCE5^7kG5Yd+bA#rPZN(S(xlM_uS-RIEJTaGPsk+~t`9b# zjjEmWY-oU(p*;2^C3Z>tPh61SIMm75XdV#5bX2=j*(XYry={&_$8K?Xj=K3IezTz0 zLKn6u?P5PAq>NyD!m8rqVm2&I}+71Ov#U>a4M2& zGp9_qx%RCpfPGs}?ct>7d1oY+RS3!CuN**erq!u7m$z1Y*Mx$pN5CB*hWIGDCIqVt zc$#%{!EDwi@U({{?mM{!Ji59+LtZ70iyD^sOAbUr<;!~z7c$xo{2Th%^g$t0+y98K zCG#(J9O_W6Hb6RmqPIY+!<;kE+%zqlEvxCB;_%M@^t~;Bh?)m~etI%3*G1>WZC#&6ZdS>B7*|0gsUb zvGP*dIqLGLA?L!L&LGW)!#~%$8L~z|ILJ;)z3<}KL`1V#&P`b8+PqI&1bcGdKSQXP36Mi%Z60!g50fssV1|k7|R70!j$R7QlAo@@p_a< zBYtA%{X**mC#-gdLzYNu%x+z4`U~MhY-Z3Xbm&dF=)ASg=)Z6yXfa-|@Bo)~T%_Rz9A z(I?{F@%A;OT)so>s$qt@fwyV~V3?^99%363LD~%soMk|KYjN>twAeiRKyF;|xt5pP z?{OFl>cXnAM+42iG#26(dHks(pQGvq144@b-u)3`$^F03&Ky)Jhlh7B=Yh+RzEmkP zS=+1mGxjq#5~Xl!ypzlVlg@jw$Ow(ik>;ilD$(70-3&!B=4D#TGy5SRTr!Lqsb$3= zQlCTMpcME303m8=>NVc@-ZD9FIyCeiMAxj<6=4)iV8HW~#<>?3XSJ_$i+4)~FQ*NnvOL`|h`k3HN; z!QA0^Y~$AJM8%G03|S?vL|;vbufPIsc}WP8#uAY#)0tG=5x%`P2QY9~&giWpu20!< zK1pVUecG~|FZl4k!;xJk0ulKwXpDruf(z?@>~v)Ozo>f4t2!5)CXjEJ5sOpIe$p`y zC2L~>sYb1I@FMh+_!~=@;e&f`8}(Ev6#}WC;~n4eADnNWky{o{F+Y1n%#p{%w~ez2 z3AF{@I+x9*twqHJT!4W=G&1|IX4YbV)x7c4L!x6!#aFWzJLO70ZQW~$&1NRHUH;qc zP~8rI#nitxqIty2!YrlB9t0jP&s{iFdYo%BWoY(&K62xJDJFsTD3_1@!V|F0E+2r! zK2?ZfY89C{hq(8#2z26d2`?7^$WRw>9Sf*wW~6{P`^-33Stq38+mtxIig(_xWwV;5 zsEFT|(KU}CfFKzlNnij0N&}IwLihpx8iW123M#y|+C7;V4>h(Fvt*3^h0v3TM{9&% zsERQpSFeDeQB)}t%VZ3^zm&MWsr}mmj8uXxMs$BdE zohe#nZiqF8fe4G(+SE+X(i^6ul%`WitLpD+8f?x1Wubz{J=|l(}{=K802alfhV{%4fC@%on<(XEaatVTM>+v^gc!qny-b>$t6Cy|3wMdgMdRmf$aFb(Uypr^xF z7Q$_${XhE#+v1UBetozRG5)4!-5z;}V{j0eOp9uZP~;@F223fO<^S{0%8MZR#wX{} z{dJtf8NS+5Se@2;cZ2fkNC-?~-EqRLF|B;E%H$8u^j34i5gHuv(yEq8n)=lWwH9@O z^p8Vo@{B10^(W_v_V2@Ds!OPqIf(Y0yq|S>-BpkQ0eGIrImMcYI81*0{*cqW9t=iN zvSlfg!nj|!JP4uN{op&z2$x(r&@$c`8O*zXz;#>IDD;gjkynR`l6CB!>=7OI>{}cp zWu>a}kCO+3A?Q_$*mW+PX-&3tE5N7#lah=-=Yr=}cWEWl(mn#2`{tE!lz3C@!ZGeP z3f)Fv_{BELW}wrP0x0O=BX8jOAT&|nXvKqhqV&9Dzy|im=_3QK*gEvvulk@_f2N;8 zL7K{1-va4Xn#8>55^zB>)QXLGz9bO8&18}Fh_Atf!>?SlT$B}s0&ZWmzmAs<()Z{d z+Av6=Oo&&gFzW}a|EoNYr4kmFm;mA!&;+;$TjHoXskJBL!c&57l3;iQ6VSt6ER-fW z!mIvT>8NgN=6D?-=38m+%>02o)zbQOM?MTYI?N9+H3->IHk&a!7!H)&N%YnlZ6dwF z4U8{KitQ!&Q8+tdO*VxN_8ANzW*%G$Xm5_f20(gNVhEMpX9tl(;*T-X5Js(+X^jWr zO2>WGd}@<)QP7kd!AW5QKUwUo<>23~2cn=N%#7oXbcWT36&@oz+D~kjWT>XKw4Yss zc4Zw&>95(HG=+wKcan_4Mr^Z|6-J1LM81yItW=asaj$Hqvy(jnh-m{Zy2Q{BSpp9bj7;b%GX%PF zVJ2%AI#4$pRzL9y!F6vp;pTe>d;fqQZ&mH$6!X4vAMSi3{B)Y})|jduhC!u07&5th zCy!%WRhruG{3>7K+AoH+c3z9%_!fkD6coVjpkcnb!p^Wi_#xE~K4rm~Hf8dkIICG{ zfTWXTxR|i9%JVLBGLX7%?Szuf8V{o0YZ%oa$rxibO%V|I0|g=*p5l>0X=lZvE4*b3 za#XeziJ`n;>f^Cx006mw#sHT907&4C0c`IepEwp>VP_H<9NigyPE2XhzwJ~i?Y&PZEHww!h<}T;VyqznQ=JqXjT1XyJ3vPnE2nfYJ zN5OE#o(E%Zt1CJJPI7M`bzp)VZEw))Examples Gallery autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 52 examples + 53 examples

@@ -896,6 +896,17 @@

shipping-crate

View example
+
+ + stone-well — A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

stone-well

+

A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 8388 tris, three materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.688 m, LOD ratios in band, convex collider 230 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/stone-well/index.html b/docs/gallery/stone-well/index.html new file mode 100644 index 0000000..72ece19 --- /dev/null +++ b/docs/gallery/stone-well/index.html @@ -0,0 +1,1145 @@ + + + + + + stone-well — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

stone-well

+

A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Recomputed: 8388 tris, three materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.688 m, LOD ratios in band, convex collider 230 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/stone-well/stone_well.py --
+ +
+
+

A showcase piece, not an example. Procedural round stone well (running-bond bricks, curb, four posts, shingled pyramid roof, windlass, rope, bucket) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

+

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

+

Budgets

+

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 8280–8500 | 8388 / 8388 / 8388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 3 distinct | 3 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.640, 1.640, 1.688) m ± 0.01 | (1.6400, 1.6400, 1.6882), zmin 0 | | Collider tris | ≤ 260 | 230 | | Export | written, size > 0 | 601028 / 601092 / 601372 bytes |

+

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few hundred bytes across series.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

Run

+
blender --background --python stone_well.py --
+blender --background --python stone_well.py -- --skip-decimate
+blender --background --python stone_well.py -- --output well.png
+

Smoke does not pass --output or --skip-decimate.

+

Exit codes

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+
+
+

Source

+
+ showcase/stone-well/stone_well.py + View on GitHub → +
+
"""Game-ready stone well — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural well after composing shipped
+pipeline pieces: bmesh construction, UVs, three materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python stone_well.py --
+    blender --background --python stone_well.py -- --skip-decimate
+    blender --background --python stone_well.py -- --output well.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+# Closed-form construction. OUTER_SIZE is the AABB of eaves + peak, compared
+# against the measured world bbox — not assigned onto the mesh.
+N_AROUND = 12
+N_ROWS = 5
+R_INNER = 0.40
+STONE_D = 0.14
+R_OUTER = R_INNER + STONE_D
+R_MID = (R_INNER + R_OUTER) / 2.0
+WALL_H = 0.72
+STONE_H = WALL_H / N_ROWS
+CURB_H = 0.065
+CURB_OUT = 0.045
+POST_S = 0.068
+POST_R = 0.55
+POST_H = 0.58
+POST_BOTTOM = WALL_H + CURB_H
+POST_TOP = POST_BOTTOM + POST_H
+EAVE_OVERHANG = 0.22
+EAVE_HALF = POST_R + POST_S / 2.0 + EAVE_OVERHANG
+EAVE_Z = POST_TOP - 0.02
+ROOF_RISE = 0.34
+PEAK_Z = EAVE_Z + ROOF_RISE
+SHINGLE_T = 0.016
+WINDLASS_R = 0.045
+BUCKET_R = 0.10
+BUCKET_H = 0.14
+BUCKET_Z = 0.68
+ROPE_R = 0.016
+BBOX_TOL = 0.01
+# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (1.640, 1.640, 1.688)
+
+# Measured after locking geometry. DECIMATE COLLAPSE ratios diverge across
+# series — bands, not exact counts. Tightened after the first 4.5/5.1/5.2 run.
+BASE_TRIS_MIN = 8280
+BASE_TRIS_MAX = 8500
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 260
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+
+STONE_IDX = 0
+WOOD_IDX = 1
+METAL_IDX = 2
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_well_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    stone_verts = []
+    try:
+        stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88
+        actual_h = STONE_H * 0.90
+        for row in range(N_ROWS):
+            z = actual_h / 2.0 + row * STONE_H
+            rot_off = (row % 2) * (math.pi / N_AROUND)
+            for i in range(N_AROUND):
+                ang = 2.0 * math.pi * i / N_AROUND + rot_off
+                loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z)
+                stone_verts.extend(
+                    add_box(
+                        bm,
+                        loc,
+                        (STONE_D, stone_w, actual_h),
+                        STONE_IDX,
+                        euler=(0.0, 0.0, ang),
+                    )
+                )
+
+        curb_r = R_OUTER + CURB_OUT / 2.0
+        curb_w = 2.0 * curb_r * math.tan(math.pi / N_AROUND) * 0.90
+        curb_z = WALL_H + CURB_H / 2.0
+        for i in range(N_AROUND):
+            ang = 2.0 * math.pi * i / N_AROUND
+            loc = (curb_r * math.cos(ang), curb_r * math.sin(ang), curb_z)
+            stone_verts.extend(
+                add_box(
+                    bm,
+                    loc,
+                    (STONE_D + CURB_OUT, curb_w, CURB_H),
+                    STONE_IDX,
+                    euler=(0.0, 0.0, ang),
+                )
+            )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in stone_verts for e in v.link_edges})
+            bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+
+        post_angles = (0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0)
+        for ang in post_angles:
+            loc = (
+                POST_R * math.cos(ang),
+                POST_R * math.sin(ang),
+                POST_BOTTOM + POST_H / 2.0,
+            )
+            add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX)
+
+        beam_z = POST_TOP - 0.05
+        add_box(
+            bm,
+            (0.0, 0.0, beam_z),
+            (POST_R * 2.0 - POST_S, POST_S * 0.85, POST_S * 0.85),
+            WOOD_IDX,
+        )
+        add_cylinder(
+            bm,
+            (0.0, 0.0, beam_z),
+            WINDLASS_R,
+            POST_R * 2.0 - POST_S * 1.4,
+            12,
+            WOOD_IDX,
+            euler=(0.0, math.pi / 2.0, 0.0),
+        )
+        add_box(
+            bm,
+            (POST_R - POST_S * 0.2, 0.0, beam_z),
+            (0.018, 0.12, 0.018),
+            METAL_IDX,
+            euler=(0.0, 0.0, math.radians(25.0)),
+        )
+
+        pitch = math.atan(ROOF_RISE / EAVE_HALF)
+        r_base = EAVE_HALF * math.sqrt(2.0)
+        add_cone(
+            bm,
+            (0.0, 0.0, (EAVE_Z + PEAK_Z) / 2.0),
+            r_base,
+            0.04,
+            ROOF_RISE,
+            4,
+            WOOD_IDX,
+            euler=(0.0, 0.0, math.pi / 4.0),
+        )
+        nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized()
+
+        def add_course(yaw, t0, t1):
+            rot = Euler((0.0, 0.0, yaw)).to_matrix()
+            nrm = rot @ nrm_local
+
+            def pt(t, s):
+                w = EAVE_HALF * t
+                y = t * EAVE_HALF
+                z = PEAK_Z - t * ROOF_RISE
+                return rot @ Vector((s * w, y, z))
+
+            inner = SHINGLE_T * 0.12
+            outer = SHINGLE_T * 1.05
+            corners = (
+                pt(t0, -1.0),
+                pt(t0, 1.0),
+                pt(t1, 1.0),
+                pt(t1, -1.0),
+            )
+            vs = [bm.verts.new(c + nrm * inner) for c in corners]
+            vs.extend(bm.verts.new(c + nrm * outer) for c in corners)
+            idx = (
+                (0, 1, 2, 3),
+                (4, 7, 6, 5),
+                (0, 4, 5, 1),
+                (1, 5, 6, 2),
+                (2, 6, 7, 3),
+                (3, 7, 4, 0),
+            )
+            for a, b, c, d in idx:
+                face = bm.faces.new((vs[a], vs[b], vs[c], vs[d]))
+                face.material_index = WOOD_IDX
+
+        n_rows = 5
+        for side in range(4):
+            yaw = side * (math.pi / 2.0)
+            for row in range(n_rows):
+                t0 = (row + 0.18) / n_rows
+                t1 = (row + 1.08) / n_rows
+                if t1 > 1.0:
+                    t1 = 1.0
+                add_course(yaw, t0, t1)
+        fascia_h = 0.045
+        fascia_t = 0.032
+        for side in range(4):
+            yaw = side * (math.pi / 2.0)
+            fx = EAVE_HALF * math.sin(yaw)
+            fy = EAVE_HALF * math.cos(yaw)
+            if side % 2 == 0:
+                add_box(
+                    bm,
+                    (0.0, fy, EAVE_Z - fascia_h / 2.0),
+                    (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h),
+                    WOOD_IDX,
+                )
+            else:
+                add_box(
+                    bm,
+                    (fx, 0.0, EAVE_Z - fascia_h / 2.0),
+                    (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h),
+                    WOOD_IDX,
+                )
+
+        rope_top = beam_z - WINDLASS_R
+        rope_bot = BUCKET_Z + BUCKET_H / 2.0
+        rope_h = rope_top - rope_bot
+        add_cylinder(
+            bm,
+            (0.0, 0.0, (rope_top + rope_bot) / 2.0),
+            ROPE_R,
+            rope_h,
+            8,
+            WOOD_IDX,
+        )
+        add_cylinder(bm, (0.0, 0.0, BUCKET_Z), BUCKET_R, BUCKET_H, 12, WOOD_IDX)
+        for hz in (-BUCKET_H * 0.28, BUCKET_H * 0.28):
+            add_cylinder(
+                bm,
+                (0.0, 0.0, BUCKET_Z + hz),
+                BUCKET_R + 0.008,
+                0.018,
+                12,
+                METAL_IDX,
+            )
+        add_box(
+            bm,
+            (0.0, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.04),
+            (BUCKET_R * 1.6, 0.014, 0.014),
+            METAL_IDX,
+        )
+        add_box(
+            bm,
+            (-BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02),
+            (0.014, 0.014, 0.05),
+            METAL_IDX,
+        )
+        add_box(
+            bm,
+            (BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02),
+            (0.014, 0.014, 0.05),
+            METAL_IDX,
+        )
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(35.0):
+                    edge.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, stone, wood, metal):
+    obj.data.materials.clear()
+    obj.data.materials.append(stone)
+    obj.data.materials.append(wood)
+    obj.data.materials.append(metal)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("WellNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = STONE_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    # Duplicated from snippets/export_preset_unity.py (not a package).
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_well_mesh("WellLow", bevel_offset=0.010, bevel_segments=2)
+    high = build_well_mesh("WellHigh", bevel_offset=0.010, bevel_segments=4)
+    stone = principled("WellStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84)
+    wood = principled("WellWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50)
+    metal = principled("WellMetal", (0.62, 0.58, 0.48, 1.0), 1.0, 0.25)
+    assign_slots(low, stone, wood, metal)
+    assign_slots(high, stone, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("well mesh did not build", 3), None, None, None, None, None
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x = bb[3] - bb[0]
+    size_y = bb[4] - bb[1]
+    size_z = bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, stone)
+    if img is None:
+        return fail("well has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "WellLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "WellLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider = convex_hull_collider(low, "WellCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_stone_well_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(
+        f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}"
+    )
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, stone, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, stone, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(stone, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 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)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.10, -4.45, 2.12)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0 + 0.04)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    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)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, stone, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, stone, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("stone-well OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        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/showcase/gallery.json b/showcase/gallery.json index 60995b5..fc6e21f 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -16,6 +16,18 @@ "mesh", "export" ] + }, + { + "name": "stone-well", + "dir": "showcase/stone-well", + "teaches": "A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 8388 tris, three materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.688 m, LOD ratios in band, convex collider 230 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/stone-well-hero.webp", + "preview": "showcase/stone-well/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/stone-well/README.md b/showcase/stone-well/README.md new file mode 100644 index 0000000..189421d --- /dev/null +++ b/showcase/stone-well/README.md @@ -0,0 +1,73 @@ +# Stone well + +A showcase piece, not an example. Procedural round stone well (running-bond +bricks, curb, four posts, shingled pyramid roof, windlass, rope, bucket) +then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, +LOD chain, convex collider, Unity glTF export. + +It asserts **budget conformance** of the generated result. It does not +witness an API contract. "It rendered without error" is not a check. + +**Composes** skills `mesh-editing-and-bmesh`, `bake-high-to-low`, +`depsgraph-and-evaluated-data`, `engine-export-presets`, and snippets +`bake_normal_high_to_low.py`, `setup_bake_target_image.py`, +`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`, +`export_preset_unity.py` (helpers copied, not imported as a package). + +## Budgets + +Declared as named constants; every gate **recomputes** from the mesh, +materials, UVs, evaluated LOD, collider, or export file. + +| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | +| --- | --- | --- | +| Base triangles | 8280–8500 | 8388 / 8388 / 8388 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | +| Materials | exactly 3 distinct | 3 | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.640, 1.640, 1.688) m ± 0.01 | (1.6400, 1.6400, 1.6882), zmin 0 | +| Collider tris | ≤ 260 | 230 | +| Export | written, size > 0 | 601028 / 601092 / 601372 bytes | + +DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across +series — the gate is a ratio band, not an exact count. This mesh happened +to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate +is `has_data` plus operator `FINISHED`, not byte-identity. Construction +uses no RNG. glTF byte size differs by a few hundred bytes across series. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python stone_well.py -- +blender --background --python stone_well.py -- --skip-decimate +blender --background --python stone_well.py -- --output well.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 3 distinct slots | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/stone-well/preview.webp b/showcase/stone-well/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..62ae041558eae2b33ffa538b8877bc2fb01b0d91 GIT binary patch literal 25266 zcmV(pK=8j(Nk&GhVgLYFMM6+kP&go-VgLYedIFsRDzF5j0zNSqi$kIzp%OYIP#^;Y zw6}g1xcZghWaur%F1yZ8t^XgxMv?wmtU&b7X_xVhu|M2;qtQm;zxL)o@#vAsf3`*= zMy{rPhnTIl9Se`7{|o=;{$tfoo}an#NAsVze_{E-@vU73MBWSkQ~Z~-U+KT<|DN&} z<8S*PyL;JVxZ}`8G@5=qB@p{{T;Qr2g5&IMNFZuuBKhgj0|4Z@jJTJ-h z1^*?+dU%ibeBgbF|HIs4`S19z`+O8ZpzSrDrR=}J?sZ8lPvjK#%mLi$l31U}Deag8 zxz!}GKaf+~Fb8v}FHUr@!vhR^(rnnvj`c}BzY&^om*D7tumQ7S7@x3h#0%oJMuK*J z{ef4I-5SimDj;+0&P){vpu770c$wOaiH-6^Ll^w6yrZzd9n#`Rh{Sa+JDg;pKD^9T z*T!ZJ;<+>sO3Ue$;T!YLlEX%F>g&R*>D+^alt#Xc52UNYs{!l1SbUyL7P6GmeW1{i zD9~%C@U570UNi1%%^ONTrg6o03Ef?Js6xiB9NIPC0?EqG{5uLbd&`yJz6Y(!J;kcf zc@O?C`T$@v@=MkG;w)<52vlA?YmR!@Fe}se&pKrQvQ_$a;_{PVb`-HcktAnWP`fWe zv)RsP;k7@jpuZ=0p{_tF$q>Q?QV+RnE`4rQ%FAp-ngO3#dXOW1Y{?|spX*y`wL#!i zLJ@PSQSH}bW$DOM)y7bP3GteLX^a1qwO78>Pi)(xW9>DACwG8OJQ+@ZoW^v1T)VW# zwAi=SrVm6yh_R_gykKXK8?smUB^ME@?xdty6y#&|*i@ldM;^Gvi1zbm3&M~GBJ&KB zY~yhgG|)>bKD%fGkXEMf?JFcFn&kE-JW(+TPmujYBbmH7n{Azn^;((cN8up>5@%~Kf2?>2*j29jW87nx&knN=S z=JQogYM?DY>+~x~ul#G7`>zS>oB^Hoxe>ciMYq768jy&nQO*)(lh`i|1?yV1bPYKn zY(Ak(uZu|Ip#QVfAoaRAFX#6;WpEAYYS#)>`W4f_>tB89OQTYmx5?p+*eOF1@fvo6 z17qN9cin+LWKh&xdVl||fdAX_>h)H?M@#y#+u#;Y?WK|~b8Rlx zfsNp#5anCj_-Cgr@<5D$aD%2vDp2L|;l1DRYGyP2z3w2%f>6&W&(qiC491BrxVpMh zMLUJ`+2KwRx{-WHaJwH6(ZHPe=Ju|<(Tu1vrhAXMnHPf3;$=-%A#0~7#2Dw$mVaCI z=uBGoRs_L#pjsSueeXZH|LO$I?TW=+H0umiT?N&U2&rpdxuvli)girsNCQjbv5+f`3{)Q_Z|6hI-&_(aH1F|W8IXX8*)AdO%!ELpT>y@_bW^z zB291#TFA39acZEAuu8i10ip^5^8>je1fOCOH_J8&Lq*eqo+k`SI-wR=#bKHd3=}xQ zCS{P`brP!wLz3E>Azv^ke-tp^1A-1()ZKR1_z^T>q?PmtewtLSK`xIuLyM_{x>}@# zs!+Hhe{nK0>HIhwy_>maz7i{sXP ztH(Pe_JZV`4?`hSIacESs=JuwzCkcH;@F%GWr0S&!{X<%)#rHD(K3|YcXh#6q(wPO zzP9eKtQ62Hs=D;Z6t{oCivXbq9`a{eD*HQ*o9AC5lhaZNlcDHFdaEwpf~M1&JZ#V9 z_!eznz%!8LtArA)3!ZZyJ>+g3Cs8M%I8fXGO3zJtYKWJ05&pP3ZJJAf2b98~ndayO68u zG8Vjvs$S!oIfu7pocC+HNQ0VTd`vGLkea{^ec1Q%>`mt$Wr{Uwf3Y9`8qcH=4L5F+ z^R1k~G~0E`&lH-I;WjFWZNmn?-V*yO0yMWWCsjEeA=~En6 zP4j3>E{TQcUar?pcFX9!9FPEqOg~cnQQLb%T$2>N!(hvW*cAyL z5#8(E8jbNH#`watFWNK?42?z@zZe z@x3B;w5Z{K5)w2a)OyB{RkItx+)F}8UF&%!yF9~)B8J!?r@lbK~3hE z$exn@Z~uU1Czo1DC&J~K1YX3_eOUdazJLSVHg1GwqfrPMJDwjKyBZ*c6|%qn)w39M zdvRc!FbeQsuEK?kP_L}aWl5134x7vX$-NQ95cA!rI`Hdv5jP(}Es}}vZ}1rBsuoA- z&XM7%JWl}_@3vOuew$=uXO=pDJ)n3-L3{eSaY-rexe9(CT4j!Fp|14R6i+3I z?e^QD^R76kbxTsPIjyQBVvO7#(~b||i6PSM)ILtHczD*Wr_IKTo>L8eM=iSkWkQX< zB(&m|1v3z|_C&X&jo6r=lj@gxYy5o&$Y5B*(zbwI9@c_a*?XIf1A~RSJSOSs^SAnZ z{)0zF7u3SfpN5Ra;`!tBmGH(!heTr&7VI6ZU6j8Uz-GFBcjXhL6I2@+S{2~|SZqcJKsRwCNmK;f~D>E7B zNoNw&`8k!6MLmaDyJL9fl>&nD*1{ZlYg!hO@;TcyL{vSYlr&lKsO?n6sDJ_qC6{vs z7CPRQQTe0rg->K#_2t=>hs?x3{a%D5iitGzm_j#b+kvlAxF?)h+bzN3&`Oj8ztbKN zt~-99(*Hei#p))+2Ak3J`&@?DS;g0RN#2hl|_}{k^j*GEB^s*rgY-6 zToDhG`J_c7p;SI}QRNpZPU^cVaGeara;kxyJdV5Sdk|q7AtaE>7@6nZ6#JEI*#;WO zqm)q3XL5w;KLcHTuc-Y)g#Ee)cdW;j$1Wx|)~nE0!YTJux``W`f{V zPreKfSeU_0^?EMEgcpJgXOPe6LF;g0Prg8taAKv|oher3-ZjBE_la^)E*#D`vSEaP zjs`fNAZ@y0iaY+0>e8PxCg29nbI6BS-q-%|0B*@O4T#_36zSHrG&2tQ*ap;=f<-K< z-A3uSz=5i+DZ#2ENLQ(k;9g`K<%=P?LsFL@XIrm}RE33plfu~0#c0))`4#ahG12= zN`_z`pEE9@?(=7`cTu(Yi`zwKP?FQ*1ylY%gHA^dUq|(0m@PJ@5a35!!9YseJj6eJJ=F;Q}CexIefF z6dMu+9_YLM3#!ep=Mf65Dv_9Y%O75s6M1Sv9{k8j66lk!l$X?NX6bODHM<`f4#f9X>$2qKYkb>Ruu-(1 zsIR;Rqj^)JbY@&j`%&9phg7y-%7eu7Lfrt2hbwzIbIOqD>w8s6ab741k-!=?m&r*# zA!J=Tn=L8}&iXP*Tfp+S-SpRenlElf&dqgg^BDb%1-g6UlToc#8eULqnj| zjA1o$UwAQV&ooP+xBl24VGOAiSQ#K3?RZvxREhc0 zoB{7mWbGRq)mLKHu@QkX$srUla3Z7%EzD}fzF6`#VyJrmHW)TVq&gsOHzj9uBeGXg z1ACul+26fE&KQFr0-q2Z@hIuHX-)}ySKK8n|J{AcV)FQ=scY0)5hk_X3dtSO2?)xK zm*Hw={P$OZog~#_k0Oz1sY2xn1h~9WD4lI~+$JzfOs}?oPS*IpI0$mnntkA{>M~y%Fl?iUT71sTcJy0=9%+MkVDoH^ou+0}Xu0 zY3>TrOL{XqtFf>;H^A4|9T!srZyWoz$%Rsgc5rz9-X_NSB-b14ygO%B4r z>;^^yiHyGG3^y&vbQrVe=ccg@U5vd&eOo7W>@jnQtl>f} zUi@qxn!8GqFRJ)_tvR}9Vz7zG5t<(tThu|nMgTzlF#>8pG2+SK^|U?O>HP z1-zf|RBr4B6zdLlV7&J!8yAjaQ@5+o>poKi`X zTQEOkD0LDMI(K$}@(Oy5{*ZVurHVYzf0R<4pJ_VyqVA}-WdTTu(ykoQKJV7}!)B?j z-Y5UNx9he8FcZCB)WTwgpyg4`G4{PcAf7D(J+E_GO~_({GTTGi03|>80W(3r9}bSCeMt(-%#9iM0ZI8zzRd7XqHKbNBd$yc z(uHcV++q9+cMvN#)16~+JXp~U1$i^9y_F5^mw4JRU(8T!V=i2Fur$wjtaUE}7F5rt zBw8%6*U|-c)*}vliY;%Q*=O7AhRCwe?HJZ!%~FfS-51QBr?>n`nl7W4g<4{8{aX=rd5z>}*$x2E-wg8{Tl~Dc+c-<33^eXRu5Wm%rFkd-E3N z7|e_3aQZ^jULfK&0AgU1|1m7j9a7O@`M9=tS;?=gwwpob+4gc7!OpaSP{@E+Q8r+M zpe=IY7Gxd5c&!SCKbBF+>r9?=ZS02+U0q^f89ru7Kd8m!JubdvPWo%}>c6YR99)Xj z2y*ZdYS~mqFl4ze43dauLpFW>XgsKB+xS<3o#7wfI=4-ZPebMO!fYgIT?kVbvd@V3 zrhnnW-$TF=<_p$V$s|yfc|eN4Gr~#`B*wz8ffYJK9&0x+C^B~cqz6z)^3LlX&^Z)g za6!8RK1RB{^Ole0KY|y(r*0qH@$L|YK?=Q1cW+TzDlb1YzZ3feD-3ObF z*&%cRUfLFdHUfLuIKBV?{`LP4&mfLtkh5G5IL_Y%5qDkd*W{gKJ(i1#A@r)VRQDB`qw$-aT#z ztn>kXy}<9*usjG+UG2Jl=ApaSRqs>~MW_tMer_Y^1#vYdt*?WSfB~?bZlg(=-}Csk zgYgHL3(k~pSka;yUE~1H*_u|akL0Qnh;TA_2vyd(_km*#w(|fCB=qK?!)&HDubl;V z3nnP7QDL#)Psb_0J8P-pWJuq0wi`zmdlFfw;LBp}wItjWPqulL^)kN+mg`WDgH1-!=JJsa2XH zv=yv5=3`la(<@n$XNwe8DvF41OD*TRns))QZoWu@{A9M;RT^xQLQSzSC?pi$H*^F< z+$@W-Ayu-dffSF*cUddB8~wpZRYT;8Hf5D^gjei~suICx2+dXQA6778#}Di+hP&LP z-@t@f&3rK#1NyzczV!lqc*JXUu&yH;&tL#3=!}FbW`w8`%=MR*0Md-lH~(!&GvvfR zX|kn?Ol|+=lVA)B(T8|vCD~QrF8GLMI?D;rg8H`bd|{WeWD>jNPRl^IZTyV{m2{oY zjQZUgZCN>!DPqAw74~Edyi0nvu`UWcn?*7OGFdfX1Z9l(jCap&p*F2y6z>!xvs+tk zL+}I}uoLTBP$Rq;7iA6hy1zM|f2DhI3fyY0m~v}7b~bnzdNX2SAqNIxgCFLAFJnS(Qgc*JNn)LBb-K}$>hAkGAO+` zfdoIq6^nw4BZ3ab>*8P-!A#KM+%E$3#XcEsNQUNXVX9wgz-{JGJR= zV+9>Ncn}C) zgAGGIbs@t)tk;BIJvnyH7ucqX*|F(o19-3-ql1Jj@maICZiIYv4Yi=bjQSctGW)D=r13nyXDh# z;Ds{E3+dS8{PM05^I^^*YQH^=nWy92IWai#Kd4BET#}*sZh{179cZOHKueTD0~XTw zoEklG&x$!ucZfX}`;(jYI38~>TW$ETR zKrD+6%vy@JAY`b;&c4u;exSbM9Ph$4gL6!_oK-2$nS&n_r$L!{cCee=#fcUvMvyocTm*wRu9#$p z$J4fxyg2iMjZx7TN{0I#VZ#-kNW7(zlGA4O)38A!HRlUHwQBj4nIRH^!K?I6(@HS( zo+nCjKDL9Sh3Nlp^1rA>Yc4ix2C^+;iCYmba@{(9M>1TH8_2i0-yU zLW-43u^24?+#qvaGkmnp@>E^<%4-MNLJ)L}H@CnF+f29)&hAuB!;pxV#^vMtmF`{d62$6IHsACTZc7_!r$@3QjypA%Bcex_SgY!F|jgo zUJJkMsmzwR;U^45gPT|6H-b&l_Ob^RQy>c)s?Hf%o*0kE3OY*mb?PQk?s4ozFw z%$_;m>kybsPq(IN<}+FuCfs~Z1K70djrWwC5wHqKv*J$d$VfQ43q9k^PHT5)?LxR6 zrU|#pBdZ({@mO1kBoxG1t2Jz{K}! zaKE6NTHS`)0h*olEY*;LiI+L&pAHa%gLpZUNz*UHZIuocP#mV$r6+a_{rrpQS(ZD* zeddgkY_W!1R4Zk^(NO|dSyWN^H15BfPeb+{P^k)8(e{yHCL!1WVNpNqP((;E&lL*I$YmrHto4S_MgXQOIQ)2 zX}5zG4Cubx82(w7-rldOpI`1h@w$o%16`UBBN#9e6 zTn|};`0Mq;3Igmb2a@fUT1Jh5EkNialDuA}@lnn|cCgimrg7`;_ zCjIMwQCI#EKfpJ^m(Tr*(wWZ9aDG-u^}m*z|sC}{c@!l}zrtu_QJ;`#W4 ze81N9ienSzbpgoZSY;j!|G(2rD)_ukM|OVWjTf(Oy;&X50BQCzs$+uv`jFL; zlITs+Cr4+H^cTPLKGqh`ETjtN$m_r^qqn=#xkgtps7Q_6zEGsfUXLsZ258r<)7AH= z+H5LPdlrrpxEM+=H9Ns{Sx8Gr6M0C{*0x4(K)Tx>*-`QpwjcZA-nm$S`THvQE@ z-wjF@uL?kXMJ;;A|5i3>c5iAUss_<^!8k^)*ZBj`&|w^9SiB@RsKtmGU{CmB+9#&p z<7C}AY$8GGQeOe=je9kpXSvEbo9F2?Z|-fa%*SORI73Sl8JBkv<7bC&BBD1zC?FZ?5N;YZShTVceHU=Lx1>{KR@l| zBpgx`9?FDXbG7`Mb*xWH{f{b5rUqH}IV*!&?Tx0oQkNrfM2H6Q|0?cm4}&KI@Nhh- zT9mRB2@s0_I2w%>rDzrOllwm0Wek&)Ex9rGrGHCfP1G>;x!YcH->Aiedq09_q2hH= z-n7m6ww#g7e^gcnT8@!+VGXG#{bru%@E(*&H5`S zoJGB9PO+E@xjr6*F6_J%y?F8(E@}+EXm3ARi7OQq>e5`!IhEvb-F%u5&!CQ1ybdJG zKj6EaAT(E7*pO|A*Z`)A4$5}BjQdd;zV||VfD(1QU*q0YCv37w{-8@JX>gO@LV7t; zt7&Ry7m8FetLBeN57pnvF)N?xfikVNTlIbVN01$_Te?9^_;ffL*i5=5Ju|*pDf0Tc z&)IuMZe zt_1>ZKf)V}p{J?+MkEdFAsnd)8Yvz#>6bo#cLQ($MQ{n6E2wLsOl{ph@e20oYMoUFX|&mi~EiFZm< z&H}dJSefx|uHABtv4Egx6)B6wRnIh}EKvCY60HfuV6=@`;_%s>v`Ik!2{SY=_ZEvE zu-+mG$IySCXJ)DHZX(Wp6Z{{=)MSJW2jD-TC|hTK9IwGxnjOu@`K!K_sfh`g7Fsox z!DvlQu{AgC&nc9`cdJlOFFvQ6IEDm|yoHB8bzQQG) zKvu{09Lqt$1_<412%E~n`fxHZR=!pK6MHBZo8yfhp4ilVHFvR_jGz4@bgo+*f^QS^ z1Rox42v!I|L@7gVo^L0jJzN!hg{2srs6uQms<=t+&DVQU#?u{sJti$Lg8zE30-TYX zh0%-zY?qvIPLW91>-SO=?ONPh_isHAX$)4-Egr?!vb6^QN$d&Bn#JK->j46u#@iP_ z!zPTJMS-eqdmLaBaS5Km@o#D9D0A%-;OUB4WpD-!lI{T|t~dFdk`XF30XvZekEI0? z#t*}Ypm@i>btGY22wds(Gycx5smFpu_WC`9wRBn?Y#@Vj`T(*h8RjgxTqm#3QDO+orP|ygYvPbYb_vFOO=x{hw9npq4-Pr+%lRWv9B>1_|<2 z+qejn56-L0t?0zTUZA+p$W*kTRAa+oH-Rar-Sl%Suq*Tr%%b|)J>!#?u@Sy-aRw&vBS>`in84bjz?rnlNAf|u&m4HmT^K5oWlh zG450pl)akA!3?q_@h>Jw(J*eYYg;1fF<1k}C z;@|t_HwQ)3H#2`W(CVZLGHLq2ZyNX7B5S+PwgN!|2A=kc6UoCI5L|1lBp|g}u)|)G z>3LV#jdYcz&7f_8H+|mJdsIQTWmS-q?Q)lIyCY`C2PFj8GuCgaEn>@H0R2(1KEq5$ zKI9d3L`9PSTY;;`;fA01jD&^x^N#k)Qg~$Oe%t$m_X#@O1AL3|LNq=C`W1fw44ot3 zJgBVepLxJ>l5n|?<0}z?K*c%(6SKZb6hQR~QvtRhE$tJvP%alejbZ*Kbcs&{fK8?- zvNWJ5aO-X9JqJlQ=i?{9rVmAH1+}*t#(^m9{= zQ3L0Qz6%TSJ!7nie~DY0P3-Um0NuBlA*I7lJbgAE&!Hbe)OUoOz_e#U?fe)iA+75z zypCB~fZ}yU*=D~F4i4w%3-%-!xf_s9w<#{((7777{ejC}xxQ*eAcr_rQc>DnGZ<+} z>m1ExSd8l_hVoWSDq_StJLUv0QsaHG0gZb97QSKd`=D0}H&Eshk9rx%V^P)7u&|jX z@x7(alVG_w?fUqrAQfisQWeCTp*A<})M)a3L?W7h!)wSVULeUSp9BB;3hS}cd6iu7 z3iqqVt|Va4r}TC%V2DQ1LS6}%2}Fh3E}csc!X!xC45SRgjb__x-)Tqy*U(QUUuQM$ zz(x4_sjG&2l~<@~9h-|Exqf91>N!|xiNq_!nW`%ZZC7vSZLP@!k$HAM;2BO@4&sRK8zZU8WMj;fUy5;rrwwEH-FItunI z-`&S6EoTzKa<*+-Ms*n!+xnq%((B=)%95osTrq87f!(8SwjN7Y@A-)uS4T*$d|TVO z8=F>8?H_)jqlhyq(CfWvTR;H`T?JxfCRl2dfc`Vb{MN_g7{@_Q`rUX{<$ZEOAq3b( zj9PJzB-Zbb*W#?AZAzJP&H}U}aoh?=zQe86D&JE<$ABUg6V%K1sTV2LQwB5{f-$yvAq zf(7dja2ABuNSxRb8D{r^;ExdRTQp9CsRXF6+=(&7|zx*df6N$WFlM zuEZB?)DHJzUuAvQ+%nG1?$WN1Hj;?c5L&)rU7E(@wB%BYX_cKjW8!gnAr13}&y_p- zA1QP^L+MlQc$U8LlkjxUA+Ew}jD7Q6e0Ys<->>^X=$<*TD1X6P^QYils))o-0pWB; z=>@8t1bx1W57$5!QU8tFxeW+|0$`;Mviq-_QO;Kus1|*s8LAAvW2VrbmhS5+0InF& zSv@^ybr=jfT?%X%hP}KtxKm+}+GstqDP8-S7YzFS#83F(LjK{d1l+4IuK(u=2pw^X z>lzsp?cG(m`wP~z0p%7Db#8POT)^P@YV4mXe34>2?6Wv?y+$p=3OIrLGALxqP#%ld z*i7i{ntl)`bB)=2)Dz{eERg?h9lPil-W?)oT)Vn5|6`&LsaA&lLJlLIILt_q2;kFT zL6ixaZXj+v+4`>m+?Lu)XMUsKr}T~*rGnBJ-w-1Q;07fSg(Ms29?oeHMDe1lG~_Zb zU-t@07TBOhVbxlJwkH+p-?3E>qi1;JOk;#}1 zqf`|dsZ(L1sq5Dg=SYb43;1^Qt{-y2h!0c+>r}EYjUsdx`Ctq^W0VN#_~)1JS3Ige zUvmKL_x>z1Qn*{LEMD6~N#51yzi^_U>m=dLnY2-20{(GF$X}2JWK){9Xz{~_I@_56 zrx0!osRN+K{l{4nC|Tq9Al$UO(>?oE{c|6#eD`0l>kj+R7cXd8i9+QmV5Qjrm{6FS zE3>@NTY-S|V;yA&OQDD@@rPB(4QNSVIbwixLb9c&EPzk=Ic;^Gx#g~_eeVP`^; z2w(_sP5eXXKL!-3;QrrLA8qPY#g|AydmDpWY?G5vFw#QhJr)aPnWAhc2mm8x{}tVe z;!qhgVFzIC=q(MSqUa#PA!q6DP(<-8Ne8G`0Uk$L)j^{VNu?1(e;NOqrE92V<WTH3d z%TQxWeIM^$5-nR-(x%={tyzgI_wWK|{W1Ku37crrwe_ZQGN(W=IVqvr7KbV^!IyI! z^Z|klRS$?0Kk3b#i&a5|4h~EFd&hxC!7749%_1^2cZ+FFXvNYons4f^lNJN9U$ADW z^N|AUP*DxvE1N%s&XshdUF26+$-{=p4i~fp=BfNXbqZdF_em6f{z$3f1YemI~eW&{z=@<>h8zFZABAgToUc;;z5 z00mKuAE*4Lasfu#{nh{n{?&&k!N~%%t?%Xf zfb#JG#7bnh_g{{>X-+TWbVk5Bc7)}amL-ed2+Sg4{!5yG4ozMc=1|+MLCA&<^1Na_ z(ia@U*X?Ob5ezE9K=XFp123953rwJtU{KxgU&xvcfH8Yi>xJMJhPj%XZ+Pw|>WiW2 zN3`h!6hNK;>uEMk)gO@_?aq$_#3?5fo1Y9J(`D2dBp8_q%A+?FEp$!@H+ck1W7>pg z`5P8U7W{H$s3TeQ7gJZn_3w%Dz~l*ygOslF$1pf8AaG6rwz3c`EJCTB6HGs0UKA2WPwPTBTo8#^p(T3h-}fn=4<3W}@zA6q3YT-=ZJbt|9r(zu z>#kwhlg_|hZr2$dzfjDIb`OUF$)``wN&=x3ShBxB6{_^K8Pkzjq?h;sKobdMM}V$jO6F>?mhhi`|y%y&&@fT^Fg zX5UGtQ>K&lG13}))ZSWFD-~e0`8xds{B8E3UZ2wrNPicm6k!sa}&yNiG0<&9-|Q< zETmMuVH&EM{-T5PR%n|>=6-(S&OOUV=bL#TvLNaVgc5Nh51uO;Wpr+t$Max|EbQ@i z6#n7{l(rOJfCTGlP-$^kd@YF1!Q9m?%E9QwXK4c|ca4@ZLz!>YR|n6W9hJP+^Td!a z>ZhEMXgE3929^@r*pwfA^QJ`IIcGD_8z{Z#or8+9en)C|`06EHz_QatY*NRP6AD`h zxxk199&Fn|&(_Dk4mAp$36r(a^CTCN)?`VKdvDNBU7?1g0pxp5Kkw*$S3`Z~Mj>Pi znLoDi1}s03pVYYj9BnT6lX&HE4LKM!Yx?|mQ{VCPWl)3_zPC$)hqb&Gl3s1lXpwN$ zX1fJ#-kH?E6t;A*0b{&HmZ-0moNQdueR15X5^ZwZ~r$V>jKFs z&}u1d9anl@5vw_yNY2w(*dH4XqI8+xkltO$vA4@Cu+KrqI&D#J^(84;s0QK7e<~6a40qHJ%yqB? z!htCBxt3%QCo=2D%v0IMHpu1-J^c+0XCv(JhAryVzlA(%X!xc~XAUr`=9mqD8 zzIFCleZPkKFWung$$XhvC#Yc5Hl!S=4>PhC07+q{*GxbHBvQ$?yw+X7g{a1IyC;0u z$_tTJ&o1$4*kO+M+Of^1*1(8P5LH9jXmj*~AK{B=+Q}cyd*!T?nRV&t+eSC+8#pyn znhR~a_?P0m@d#SNVacc+jh9j^muyYi?JQm!7Z}j8j1>hvxIzZuE-f%HMg)a~ZP(h2 zq%Ou<&JbIV(R>18!KB4_XfQVM31tfYQR*8EdX3z+5U9eHBbAC$419zng`vffO}_3uct-i53j z@A#w+7|;L%u(p+sO2SsM(3QthIqaI@p~&0bTh(Ed>~`A^BPV>)7C9aOp1hhY0C3;u z9V4^1&*2(2WudZyMpnd0GkdMDZEM?o@s)$zuZhL2`Tu|U> ztq1op=aOD=u)V)rR;@4{FPQ*q>mo|={#?&^spj%c4XX&DI@gmmjHQdOoGxe1?XiGE zETj1Z#W8EoTr_?+Zn#e}eC8+QDutHC@rNCS{nNOwVyZ446cEd~j9dKBma#?c(tTWw zOGo-u#3mKnC0LWbzBRc)H^ru{&EZPY?Z_TA?#$9!B-(JltXcO??CbRJYQhrCr~PW( zmaZPW$o$=uA5jeo2r0{s#f5Me6|=m^cG11VJ20Q>-Mk*1uffHe^TWgMD{n!`>v10v zZbK0DBcBwjK{Ev~k7g_rI5gb%oC%p_W9+_l*dwVQHw4nVA#X@9_q7mMb|If<(1Wjt zaz@yq1y!LtUwJZa)m>G-%mVo%_C-Hls-%40}l=M z%<>GBv&#dBKxCp&AaU`e)u(X_bYd_>gSV-69Bb1A@PC^_J~ye0DHv8opiwsQMQhj8 zSCK$5H0XQOD$uH~t?8j-Mssqa&41662`hLPEXke{&S_qmzylX?tw18tJOR)-*}EK~2;#Z*{Ga?srXqtTAyUGAztbD;bT2w(WW{ z?LDH2KHu_L;_tVu#7{G2<0x-lFh8j7JG9&^k38P$2~YcE7&+=ZS??YO5IeOioWj@He5t3*CSX zIR2jnW~>V8^C$&W@hicef$W?FM|Y3v^9#3b`*=~Se;HiiX%{_2~l zC+}U$n5nf;jk=t%BnpGt^pdTv4oqoy&}i0G-z}e6*09Nc*SCmK>(KD(o>LnjB$hwV z_*Aob)>_-E@k>5UZ2071dF9}5CszjsrX=`XeJR;Py)xKyHB4xeU1JSr+=gvfoWw5F z??t1t2`k_L)I#h5evwr>@28%JWtVQSLr1eA*_bf%B1=)~Pi8KRm{2_XRjcn!^z5~H zC@Uh<7p&i)045_u2YA2SrGF}8+_bOTnL?#U-6=spOwW!-(sC{9_&9|VMaxvQrA^2- zT4$8lNSyiRKT1&|*+4pV15TQ2hNy)SUkgQvpCOT*F>o*+&=N{Oed|9ee^>LzM4VsD z#diNGtiH69dWKi~?KRZWzEcCba!6N|94p44Umr2NqXw1c!@ak)1Ndl?V_4n&9UfKG z>~WYgAuT_aG3%&DFn};@dNa`_XxC-YP*0O9LqE6mJ|D(Z6DWZNVeoa54rqk@T`ahP zp6}Z6V-v|$v)(}Pf9-pWm_pvhh4_k9kFc+kHE?afxEGzts?)s>U#QSsfx~qJ=EkG} zbWAFC+SFK-T_K4!>>^#~)tWV97{`2H*WPfoE6xF=Z_IUPY*d-4WDb^WcGu1~$N7ar z*eKWUhniV7Har@62ixxB*Fm|Bv+RA|aW0BF7vln7Is>;t%z8JPuXp%onjm`1YJJ|S;E zJLCajQWpj67|M@)^G%swKfgSn3%4m(bnP!=KXa#`gpfi!mjj=iZ~(M|Nx@-^SDFmk zHeHj3ge6gaq?zmlOtUPG3iNzAyVYZP+=O=*sQ{StBQh31;;}7s6eJlW&V1EUKkb%G zZlw9)>4QW<+(JBJE03a6eUTL4bnp--^~v@w?|a~e#UV}~U5ZlKv~I2hN~OFm!H%da zwSuDk#tiIP3ft9LJNw=xqW^V{THbaWYQVVs9I)U7*NZ3~-#C3{@vc>u{*x8bGZ?Q7 z#>Y?ya5ms1WNj`omEXNaK0waJ#_@55nHDCZ1h}9AANx+-!Oc^e|N0%OFyxp?Vs8;hIif!asxEqqhZYX_TUL_ znEmf!`2vxl5mE1^9B!_LIhV!paJBpIFvujOeL)BsMAO1h=PKcoO@~^!2y?T~!S3-n zsH*NST?iflesqKY1zeooaJkD&=Piisv#%*jn+gm-c?8#vvlx@PD_rh;FF~l7lA?m9 z<9auswYo(tsjkluuhehHLO^IZ~{G97;L)D*YD)~V02~% z3^7AiAao(6!0yhl|5k+;O7VuW9STFr9U6@^pwKt2@N)BB3$feIhyFfdb$coeJ|+Df zaX@LX@M0s=(!d3@5i`4D&}|a)HW{b+KQ?HaL#p)NbJTZk300`0a<&mG6fGcJz2Jo{ zL`>CjL@xLEGUdYIB4=EYrd;zeraD>>4TJ%sB3#`iJM?oiqpW;bK}i!Jwe)F&X*aB;mSGW^W4RX}j4ZOk^oD^hJz!6P#ayW( zf@tL#)Ws+&55TOF0fuU*1K^`Gp@H4TXH=YRHKu&k802E9|BU!-dI3t;mxy%85W`@} zK3U%)QN|O!fIm2mh2l(u#rzHyA#KN(F(Ey!0Kk4*Zeav1jM3cN*}(iC84Qhm28@e^ zEmYdeBtuYojbJ{~PSZW*S-W{|Q*_ZH8p!h7eQ!_MzzK>2pHN`wEmR8;_RZh6eKPU` z3^xyy6@9YUWPQPs0#*I|F>&yBavAc1b-RzOTonUiyaz5_*tTDtc>c)5EO4g511{kd zT>wgHT+g&jE10f}7Vm~LnK|X@y3oq+n;0d3q$zOx4D&&CRWKC#QTAc@JvOY5b~pnE zv7AID+4#do-%Ti^@$I>=YZN5sLHxkFx=IyPX!Q{tJMD2eZ4o6 zjh!sAuL7;Ct^+R?O8kXqAqy$gf_XP2p?8vnzQDiFEQm-iIGPNXrq}z5M&v8^=wdN|4ZB!#3vncs8-;c(+NDe0`$A>>$I#yq&A?Gl7#+g^f~(P zSVhogtE&70lZh`f5W}vtsIgvU?58Irl>p84H_BZAD-Zp}W6+dIp7X^ahzK|tsA4gq zvVcEl4}Xq~VpQhdWB1sB=DLK-lnSe}0m0$*j-kho3E{k%mrum*khpAR6E(L8wEfP?VNE?WVy4`jPYKFJYPDZIfI*H2 zV(EQDk9H+Q>PakX&^$?nXkRN=5C*1;R^Z<($&e za^_jIDW7wws25woOZCI~K1hrYx|#A~P|k<`Ik1e?d(yVAM(%AQs0E(mpc=D7*}XZ- zWGn6LRIp1y35*DnYE(;6P!SlRPn@kbvSMo=Hq=EYC<_w7_%JvqN5^n~N)-X&Ir!QT z6r-d_n2yr#0++dhm#9aldXy^U;3CqR;PP7kmv5L9#M7Ka6!(dCSI8`thp5y6nE6d z^ygg)RQWc5YIF&yXUqvAI+23>plC(6LT(hL`+xH$0#?XiIUbsl3LCg~Fl{q&RcxLN zVa)~9$1|TT2IG8G<>J^7v5+wi%W2;?#j8|tGPi{=Oh1mk#HSqU*3nr~>K7x%e?qyz z1>#pKQ|pS1SasdUU%rffNaom+lfmz8r^g}>C|7~%d+So_czmxcjmmlaihZC43!UOc zy0Xi0lD1Bg}y}VSm_Y|!ffu>$u z6B(w+;F-v@TJBa>a>6OMeik$lALz-|SnEW6t^|^mdI-%UNGt+oieo&vUST-n)&y$W z4-0w@AQ9UiP&Jw@*aARWwcP+DnEW6w$l7W>y*|pA3d_7xje5yQ0pT_!dun-Ab!?>l zZ^Ly;Eg<~ZLQP%l8>7Mq-G8y@9m3ACXlTrlfo-6qM8rr=!6ye7aq7jcLnGSd4gMyej>aa2wM$ zZFebe-XdU-M|Ise2g_rqXtV+S=L~o~DkTCK+|3UrH-S9F{vm1`8+J=IjDlYVoVWCa zM(*JAU*>>$@(=ns1jI9#->CG1zLC}0?oN_?#fk+o#0uy^Ff9r-1h2ug_2s3$E z@@S>ls^ph1oZVkb%+3h5T5g*RBZ|*r+{u(5sjvA;;atNHu{q~o#X(52^zK9wlI>uO zfPNLvvM5Ynx7(Q}Q64H~Ru`6|u5fr2n)isv+OLyf z$nxwLRX;Pn4-2q^{5TsZ5F`mj_z{;|^IKNn+* z%l%F~u>Tb0IiSuL64KZr7flE{gk2jI{<3bRxp9HgV0*rUr_1A5_KAgr6&d?Uf8l2@S)cE#LY-eu|VqReA8iF}wGsC~XXAes@*>Gu4V_0U4t3E9BWQCbzel{(2ZJ zQuJ%JTaoR@8Qk2QKc^*ad$32ixd#l>wSmR$B$>9`d*!y|-<)Fu(UM|I ztKB(hR?81jy|H%uZ)7X%BtCHhYna4x`M>r!dUB5H4|`5w3=t7k4G4}*$|#G0;u59v z|7`*uW4ad6tt&qrOvjVCbLAOXIo7Ug1oD9&_UgM13+0d~RLbw>@$k45J+})>!AHkH ztmw#p9?lc^u14juOolQ#Z_7aL-P)lWD=_ z`{Tggtf`_v1!o}-u4|7~_O{JJG^lF)2C$AU3>jus*JWYMWK5;9ocdngkGh84U@A7< z4h`U`8Q*lbYI1K1g4=j6c`2*alm?Z8{&T0gT`V6^28wEQeJOr04s`NQtZIGph%eW% z{VG~RbPc-05h6jwn5Ytkw_x&b9_*!hAQ`|@WlOJvLu}(edG`#tyi43cH$4T{Rh-*U z)}@Cm!`2;mFJCSTrYu{_E)BD;>5aFvnk_XmTv_#I(!l7@M=~F8nB62F05%oyHsuNt zGQYUfqzk83<`66$jy`?&(^-b%^Pe%Zwb(U2K2^L3V2$;%b(Wfpz?@{8fMDx9Lgksn zk0{@84x|Ecd{OStg3VLTvw&cIFSFa9+zONj-!(HP8-Dzuj-E zKyN}DojTM!oHuZpUJIWU;FPD<5~)71>H=I9t2k2pH4`nnNs&Ta)GvX~SX>Tv8Qle2 z@>e47@GAC6&u@1($>_s~O@b64 zOl|9IGeR*bPW9PSZiEYN)o=8|+V!S7B>-VJb&Ze_bF_RZF*;*KkXF9m0!g;zdd%rY zJ;dR1-&_N^a_TwuJ%kQ&pItl}wtJiYw1tee>H}OTXuuCT47pUunx@yvisw-z*&_*0D_3%&I;`U zVPcEdMXuT|Mk*lG1x}k&y(mT%Y0Z~g3Ai{OfIWKjcC?Lm9{9$2%J7~=spR|Trp2S#g~#QBT{Rp&!T-7|12yQ>kMS|HBM6d^-A zGuEbT5%5GJFr<;vagAReq^OCp2*X0fV$h&uFQU{LWH_!z|9z&2k=ZGS2&W=u4e2~3 z^2|L5tK4lTw_g+z5dSYcnR~aYXJRiXn{ZgZgz9@4NX3{sRa3JF^F@}G)nf)Eh~Xx6 z2wpBW*HT!7E=UVTsZ#S_E? zdG}FQf?0*E)8{;Dy;aae-~pD%ttRd^7tOmNb5WV=qFc7F@#ux1OzEbpD{jLnhHN8)Vy5*Y)av`CR=ZLj z_dh7=6fslGsi&XudOW{6_NLaLmgI#P9KH~b<2?(ioLGEr`N9uKdb=Nm^Z<&kqP)J1 zngcUu;=MaejXB+cmFzB>+HMF4g*9xPlr8Cog;>yQ_!6u0?5y^v=rRqWtKkj zU;$1AOTD%Iy{dH8dO{_rQE1M7`)fr5DiV^f2UK`&F;~SL5D;e#z(p+Mqp>d`BLs!e zcQWGh!x(B7Awg9#{myrsAl9R*2}9Dl;8&D+x~{`PDHj%mtipyi*FFoX&oiRd4K);P z7~gc_$C^v@j-e92&Btisy^x&=Oc_pNNx$9k6&K}VaPTQFjsVqWSZ?^1xDZ!$5;j-8 z4dhD%XR6krv8*JvUkYx>q-Pn?zG#EeOg&fM1^C3#^+jzBTSf`POuLh>VPCl%yz0q5 zI{wWdhSVaFSuPE^wBJARvp)ivjVVYpGV8j4`ROW~>niC?@+a5@Pu(bhI zp>_+Q!kHa#em#u_%4HdkVXVvHO+;0qo+nAYdIZtrA(f}xS7-G|K-}+#Nw2gKzWKdZ z?!O>f0j=U?O>A0BAO~KCA<|kbyq6IrRzS|Y{vhozPpBoma{^2wCGt*frvSv_8Jn-Q zrCK6OTo{drG5PHVzbDd=AHvzfEd82QZk0e--7tqiR?EnKVdt*NxOz1^>}%+*uVbT3 z+LOFIk075kJIz9kgvve_{s9Cg)`?LYUBMx##Tac$2z_ev#R-tmU4@XNj0K3Ku~S=i zFvKZJi-eJGVA)Km@0sDr?@CE}q2x&KrVf(WXXlVvU+0?n4V7AL()$E7Bkh0_j)cr1 z%ksZ#3Dr+bMJdsAGeI08)C)TG$Np>>e58CtqU^+hB`iNXZ9J_}4I+xQyZ5zSFYa#y z^lZyzOFXQ3?cqso;avR?oadI(=40$;EMf=99^C3*nns+@E1RZM3#gEma6B)W+Q@p; zLb(P)I%0D*ESfHln|FUh^@}w6))oTNH8-^GUH>^%y(FuQpIf<@M*@H#$Yb#x3ZzZv z{1RfD$We_Sq*ga;RA8Pzx$~4UMmv6`P^~JTA!en8zcRbR3r(--GAa;{%vTzi&^uxg z0_s4v4bJU*on!T#0(UQpFJJ>IuA-7_n@N{6GY|c=yxieKLM83zR{XTeW6%Im7;W_< zYklu|d4>~Fp>)|D;uU03R4Y+2cD?=u1fPAg-`x4#xaqV~2QUHxiPT7d8XEI)9=8c# zYDSS7OB?E*`iF)un6E76pvVP=EO|QN==_lNd{#~z?0dvB4sz?@x7f3^NxKQd89o!5 z*Wx*#KAK_ZpL#t~bLM9=zF6KGl|@&aLTlzw`Y6C|qPtG+be0n?5L(=kZq{(g*~j7& z-I$4M@Xe$$YY`vehx8oWkH7>-)YPLyGzkK@a<*4)WftE~%5j4S%hszP?BXT?Z<&Be3WufZsL}X= zq#*9w4xrUSEoMMU_VqQ?CuJWu?alN$9;F#5LTuXcK*$4ctW%Ihb4ML<2?{UK0SW35 zZf7Zr(FwCqEUgOKaJF0ID&nTq_U$dh6E({CZ4-?0bS27JXR+6=oAFyN0O|E{UK1mI z0Vgemd}2?aB%)Z$O#b_^mM|!L-8r!lH21=y3ClX>6PG1*yip};a`5y9sq{Fu<{zMdFeN|5>)E2n^mFl2H93VJ7uw{K!TN5&rPzuIlEn)cZG)j{7#IIMPqA zoZ*hRvJrMJQ1AJcmHA4T%u^Kmr!P5keSl8Eb1VYx&^za;qA&gab3|RmlDLLLxxQ7h z37EEUU(SZ<(;!G~z$MJ#X?=ZJf(5m$Of#iOZ7eSfcmv7ZJMtS1(-j_!Mn?z%z|!c( zw(id98)!w;dfUa$fWS_536)KF+QZ&P1*VD~u!OqGiE3mlHgnYHh&|K)v(mT9Iza*X zMeCj8{M1*BhEo=JRmhrB?reFFlg(UXlP$z4t=hc*_LZO0fXI-mxb(LiYoqp-Q1e*T z-Kw8+ei}LQY&YwPm=Jk_S(%^3dJ)!x3YzbMcI=>=)3&YSE`dV9$EymWZ^=;wl>I3| z?5ZL|XM~KMh^aG%%(lzk!aB6@zNa$^n~E|kX&_vGlf+2lf=edtm3aXd0EKDG5c0_V zQq|$?s`KRq>~@`1Eu~*Jd`|0Q3|tgX3fLyL`l8iQsgk03YoNJ#H9IEOn(B zK<89K2^tRw5Rku3zbhUQgSxjjhqCqiTLK;cHf!V!l-UVHXx_(0y46JffM;Ln0=3jjT4DKObs=O{{F0WREBoGmMgi!c1{Zc&;)5wZujZ@_?As#v*?VL5 zWpl`J{h1BF-%XW7}Wj z@7VjXg^XE7!>L!fPC2oF+u%Y%xGqB@sW_HMdDG(}a<3>N4--XQ0JOEZouBdyV0Um| ze54*-*?pL8821TU@y!9BFkCa6_h^?f`*YJ?14lq@rx^*@6y;WAkwBZHCHv^Ce&sj`8=!;mj*;sL5Z&NRIbnoryAm;SfF&i@!N-vy2ml&d8=sU+{sOPJ zg&3W`%LOBs+826aO?lT^yR~6%*+b#Q>mzBLpIyE%7PM=@ruAY6`fcd_eF9k3>4end za`zWOY}VB)Hz^y(TC^~pWa3!Tc4cNpgH^FTxu#%%(M$}Obna@vo65O}cGm8>wiAdX zOnhk}{2EN&aQe5$qLD7wkyQukYB`|@-`^(@OI1$v+Tx?vZe1RTp}Q2HF7t#q4yt}i z0Y_qLMfEv2&G7*Oo#OJ-r`8+Z zwqFtVz4M5F;Nv4&No|X);a(OoAfyynRU|{M0254(E&LjEwRSTD&!MqZ_WBd7Kt!Lx z46lytH~?1=0KfqoyNrvmz*5NHyxdim+s_}es`U(Ld%ER@=~o%3`@=i@mX+6gbL^O? zIwG+EaHWT7+O`lOx{kvKC>hE5!L5 zyhs_s^EV(D)*q1r;v&(=nUOvY<$fj@g-O9o-O9&qI>5*KW9;+0TaTM{L09OZ$OJw8 z&xj}rMRV?9C{))4HYfW8%cVg{ueRnc7*Y1nB)4qAD6U8Flp+exDYnoqm$lFL2 z>p z$OL(oj71iZ-T85iQ#1{P4^vydN)xbY>^OGH#~+O%a+AFZSKZT8vgeRzjU+--l)0A> ztaf-{L-NyJREsSnl8-gnMFMWb`be$R7?deuN2f=QL=zlUNzXc88z_=n|R_VUmiKmKp5Z?#~3tZa!(}VV#byr_A$G*((*)s(jX#BR^CKoTx1e)GfLr{>p z!39v3-d-TTrSR@W6V)pIe^$OI<RDYeY#kF9%TUxBx8UQQ3XHV zg4Qb2Z1lQCO$*{kR}v3z(KYgsI{J0hWBmN^s$uWJ>S7t1pfTi;jgO+S_?PFiNg*_V zaPH)T_`IoPWUG#~k6=LfE3Ix&0O6iQmFx(QWHrj-LL?D*LR??wgBy4quNb{HtCSCnbw5|q;BY| zq}Hn)s)sY{`Yi%U%nKpMEAOIDM0J_N?@fp#>f>xB&6`Zsxn+D~<3Qi$A)}-gB2sy| zN7G+#>s{`&slqo&7g)i7P#9lorGVl~oVr60902$a?u@PPj25Wamh7&VTQ7D=sgN~u z^g)698T=vOH{abEwF5v2_rb1g@DnPkQVFTnOj9L*f!z&GX5-XO5O}>^P$721sb|eN zTbos_Wf@op!XY(b3LfTuTmm*~Zpx6(Yp(I>bRdk`g+r{-EZk?dV)y%&12MFrZjD!` zs>0U`%+CY5;MnB+f)A*NC@KYb88l0MWoK*Qk0z*=1`hiSM^mjLouk6UeC7^xT<2m! zBek`$2}r3`-9yx}kb1Y=z?~LGJoF=FgyCH+7T9O}HsU5WDfw)c-z-V$9SAKfQOd$4 zMW_5qfrKfk#PR)7f36Vy zZGIaEB$f!_Q4s}H@9NW-wR&ZLp;f;JNKjCn=?~3+ZMuk%d=5izF$SE#Pmi?5U7BskylqvAJl>gy;+to_Pr#<-w3w9)^9#t9q|Lq*Y?*x zQrh6F!<=rC-US|3p@*qr1_-f~02DW}q0F>KgzmlY-Xgr{thjd^2elcMT$Js{bP}m) zmJ*o0rm)HJVAk}k;gnv6X7%7L6@Wu*i?`^T9y%dD1D|B7K{?b~q2{}w3_PS?tJU?X z4k7(o!37vY`KZOaR$9_y@FhpW=>eG8)gx8fo)j&zVZ7f#Avl>sHU=1p?DY}Plky3O6MDCm)Wd+ zvRqe^My~TDt&UO*gcAd_gJ8N>BTRbY?JH14czhPLb$HnR!q26*J)6c{nb&$2Y8@4q z-B?bj^Ax?UxVnlQ+_KgfUV5;W7ob>*0n}>~5%EcCwbrcg7gczoYdr7KM*}?@HaNvo zNrEYCnKDqDUIkr%YOyA16!<(HPGFHw_3JUTT3N3@TDET;w|k={7HUbe1;RgQ5ujE4(papqUO^;5Pn>a!E_xEO8h(Kf629)YLA!IP=CmReBmhh@B z?V4Z)@{6c;@AvkG^BMfR7^<9{he83AELmov8_bN|p|Xm0QM(gs4^E)_41?#)t7g1q z=&t?5W<{WfPEo8Grv$A>RrZgjo=la{#R2E+G(EK;qiy>oq`iG4TmcT+zqvzJ$K~N5 z2W4VN9}9NAfPD48Ck8H)j3H3$uwC$|EM`9f0ZIA40BF`x9iZapaZ9zE_Lkq7vv-mG zsnB{QEt-Mg0De(W)uk453K8=vT&VSgf>*d}rK$c~Tq%qUj7yXS$L%qH@)}l4_lv= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_well_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + stone_verts = [] + try: + stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88 + actual_h = STONE_H * 0.90 + for row in range(N_ROWS): + z = actual_h / 2.0 + row * STONE_H + rot_off = (row % 2) * (math.pi / N_AROUND) + for i in range(N_AROUND): + ang = 2.0 * math.pi * i / N_AROUND + rot_off + loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z) + stone_verts.extend( + add_box( + bm, + loc, + (STONE_D, stone_w, actual_h), + STONE_IDX, + euler=(0.0, 0.0, ang), + ) + ) + + curb_r = R_OUTER + CURB_OUT / 2.0 + curb_w = 2.0 * curb_r * math.tan(math.pi / N_AROUND) * 0.90 + curb_z = WALL_H + CURB_H / 2.0 + for i in range(N_AROUND): + ang = 2.0 * math.pi * i / N_AROUND + loc = (curb_r * math.cos(ang), curb_r * math.sin(ang), curb_z) + stone_verts.extend( + add_box( + bm, + loc, + (STONE_D + CURB_OUT, curb_w, CURB_H), + STONE_IDX, + euler=(0.0, 0.0, ang), + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in stone_verts for e in v.link_edges}) + bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + post_angles = (0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0) + for ang in post_angles: + loc = ( + POST_R * math.cos(ang), + POST_R * math.sin(ang), + POST_BOTTOM + POST_H / 2.0, + ) + add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + + beam_z = POST_TOP - 0.05 + add_box( + bm, + (0.0, 0.0, beam_z), + (POST_R * 2.0 - POST_S, POST_S * 0.85, POST_S * 0.85), + WOOD_IDX, + ) + add_cylinder( + bm, + (0.0, 0.0, beam_z), + WINDLASS_R, + POST_R * 2.0 - POST_S * 1.4, + 12, + WOOD_IDX, + euler=(0.0, math.pi / 2.0, 0.0), + ) + add_box( + bm, + (POST_R - POST_S * 0.2, 0.0, beam_z), + (0.018, 0.12, 0.018), + METAL_IDX, + euler=(0.0, 0.0, math.radians(25.0)), + ) + + pitch = math.atan(ROOF_RISE / EAVE_HALF) + r_base = EAVE_HALF * math.sqrt(2.0) + add_cone( + bm, + (0.0, 0.0, (EAVE_Z + PEAK_Z) / 2.0), + r_base, + 0.04, + ROOF_RISE, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) + nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized() + + def add_course(yaw, t0, t1): + rot = Euler((0.0, 0.0, yaw)).to_matrix() + nrm = rot @ nrm_local + + def pt(t, s): + w = EAVE_HALF * t + y = t * EAVE_HALF + z = PEAK_Z - t * ROOF_RISE + return rot @ Vector((s * w, y, z)) + + inner = SHINGLE_T * 0.12 + outer = SHINGLE_T * 1.05 + corners = ( + pt(t0, -1.0), + pt(t0, 1.0), + pt(t1, 1.0), + pt(t1, -1.0), + ) + vs = [bm.verts.new(c + nrm * inner) for c in corners] + vs.extend(bm.verts.new(c + nrm * outer) for c in corners) + idx = ( + (0, 1, 2, 3), + (4, 7, 6, 5), + (0, 4, 5, 1), + (1, 5, 6, 2), + (2, 6, 7, 3), + (3, 7, 4, 0), + ) + for a, b, c, d in idx: + face = bm.faces.new((vs[a], vs[b], vs[c], vs[d])) + face.material_index = WOOD_IDX + + n_rows = 5 + for side in range(4): + yaw = side * (math.pi / 2.0) + for row in range(n_rows): + t0 = (row + 0.18) / n_rows + t1 = (row + 1.08) / n_rows + if t1 > 1.0: + t1 = 1.0 + add_course(yaw, t0, t1) + fascia_h = 0.045 + fascia_t = 0.032 + for side in range(4): + yaw = side * (math.pi / 2.0) + fx = EAVE_HALF * math.sin(yaw) + fy = EAVE_HALF * math.cos(yaw) + if side % 2 == 0: + add_box( + bm, + (0.0, fy, EAVE_Z - fascia_h / 2.0), + (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), + WOOD_IDX, + ) + else: + add_box( + bm, + (fx, 0.0, EAVE_Z - fascia_h / 2.0), + (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h), + WOOD_IDX, + ) + + rope_top = beam_z - WINDLASS_R + rope_bot = BUCKET_Z + BUCKET_H / 2.0 + rope_h = rope_top - rope_bot + add_cylinder( + bm, + (0.0, 0.0, (rope_top + rope_bot) / 2.0), + ROPE_R, + rope_h, + 8, + WOOD_IDX, + ) + add_cylinder(bm, (0.0, 0.0, BUCKET_Z), BUCKET_R, BUCKET_H, 12, WOOD_IDX) + for hz in (-BUCKET_H * 0.28, BUCKET_H * 0.28): + add_cylinder( + bm, + (0.0, 0.0, BUCKET_Z + hz), + BUCKET_R + 0.008, + 0.018, + 12, + METAL_IDX, + ) + add_box( + bm, + (0.0, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.04), + (BUCKET_R * 1.6, 0.014, 0.014), + METAL_IDX, + ) + add_box( + bm, + (-BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02), + (0.014, 0.014, 0.05), + METAL_IDX, + ) + add_box( + bm, + (BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02), + (0.014, 0.014, 0.05), + METAL_IDX, + ) + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(35.0): + edge.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, stone, wood, metal): + obj.data.materials.clear() + obj.data.materials.append(stone) + obj.data.materials.append(wood) + obj.data.materials.append(metal) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("WellNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = STONE_IDX + return img, tex + + +def bake_normal(high, low): + # Duplicated from snippets/bake_normal_high_to_low.py (not a package). + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + # Duplicated from snippets/export_preset_unity.py (not a package). + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_well_mesh("WellLow", bevel_offset=0.010, bevel_segments=2) + high = build_well_mesh("WellHigh", bevel_offset=0.010, bevel_segments=4) + stone = principled("WellStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84) + wood = principled("WellWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) + metal = principled("WellMetal", (0.62, 0.58, 0.48, 1.0), 1.0, 0.25) + assign_slots(low, stone, wood, metal) + assign_slots(high, stone, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("well mesh did not build", 3), None, None, None, None, None + + base_tris = triangle_count(low.data) + mats = [s for s in low.data.materials if s is not None] + nmat = len(mats) + distinct_mats = len({id(s) for s in mats}) + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x = bb[3] - bb[0] + size_y = bb[4] - bb[1] + size_z = bb[5] - bb[2] + + img, tex = setup_bake_image(low, stone) + if img is None: + return fail("well has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "WellLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "WellLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + collider = convex_hull_collider(low, "WellCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_stone_well_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print( + f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" + ) + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, stone, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, stone, tex, path, engine): + scene = bpy.context.scene + wire_normal(stone, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + low.rotation_euler.x = math.radians(2.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 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) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (3.10, -4.45, 2.12) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0 + 0.04) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + 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) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, stone, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, stone, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("stone-well OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 8e94463..0b31c93 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -74,5 +74,6 @@ {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py"}, {"name": "eval-mesh-datablock-name", "script": "examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py"}, {"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py"}, - {"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"} + {"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"}, + {"name": "stone-well", "script": "showcase/stone-well/stone_well.py"} ] From 7138e8d0425d284f91b3c3465ae32978ae1a017c Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:43:47 -0400 Subject: [PATCH 2/2] feat: add wooden-barrel as a third showcase piece A staved barrel is the next budget-gated prop so showcase coverage is more than a well and a crate. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 1 + docs/gallery/assets/wooden-barrel-hero.webp | Bin 0 -> 19128 bytes docs/gallery/index.html | 13 +- docs/gallery/wooden-barrel/index.html | 1009 +++++++++++++++++++ showcase/gallery.json | 12 + showcase/wooden-barrel/README.md | 73 ++ showcase/wooden-barrel/preview.webp | Bin 0 -> 16856 bytes showcase/wooden-barrel/wooden_barrel.py | 685 +++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 1803 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/wooden-barrel-hero.webp create mode 100644 docs/gallery/wooden-barrel/index.html create mode 100644 showcase/wooden-barrel/README.md create mode 100644 showcase/wooden-barrel/preview.webp create mode 100644 showcase/wooden-barrel/wooden_barrel.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 25e0104..f9c10fa 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -139,6 +139,7 @@ ], "showcase": [ "showcase/shipping-crate", - "showcase/stone-well" + "showcase/stone-well", + "showcase/wooden-barrel" ] } diff --git a/AGENTS.md b/AGENTS.md index b598fbc..dec8a32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 59 -examples, and 2 showcase pieces (counts are CI-enforced against README.md +examples, and 3 showcase pieces (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; showcase conventions: `showcase/README.md`. The render look is specified diff --git a/CLAUDE.md b/CLAUDE.md index d228d8a..32b449f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 2 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 3 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index ab85e1b..4909166 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  2 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  3 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 2 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 3 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. 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. @@ -77,11 +77,14 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall Stone well: a round brick well with a shingled pyramid roof on posts, rope and bucket, on a dark studio floor +Wooden barrel: a staved barrel with iron hoops on a dark studio floor, warm wedge on the back wall [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. [`stone-well`](showcase/stone-well/) — procedural round stone well, shingled roof, windlass, rope and bucket through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wooden-barrel`](showcase/wooden-barrel/) — procedural staved barrel with iron hoops through the same pipeline. Falsifier `--skip-decimate` exits 9. + ## Examples Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on diff --git a/ROADMAP.md b/ROADMAP.md index 96c4064..46409b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -108,6 +108,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Showcase sibling tree + shipping-crate pilot~~ **SHIPPED** as `showcase/shipping-crate/` — budget-conformance crate composing bake, LOD, collider, Unity glTF; `--skip-decimate` exits 9 on the LOD1 ratio band; DECIMATE COLLAPSE ratios diverge on 5.2 vs 4.5/5.1 - ~~Round stone well showcase~~ **SHIPPED** as `showcase/stone-well/` — running-bond brick well, shingled pyramid roof, windlass/rope/bucket; `--skip-decimate` exits 9 on the LOD1 ratio band +- ~~Wooden barrel showcase~~ **SHIPPED** as `showcase/wooden-barrel/` — bulged staves, four iron hoops, lid heads; `--skip-decimate` exits 9 on the LOD1 ratio band - Procedural terrain or landscape showcase using Geometry Nodes scatter - Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece - Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract) diff --git a/docs/gallery/assets/wooden-barrel-hero.webp b/docs/gallery/assets/wooden-barrel-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..15b15215d05eaeb313907aee794c7976eb1b7c81 GIT binary patch literal 19128 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 Hpa1{>k!reG literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 19f9e61..98d5568 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" /> - 53 examples + 54 examples
@@ -907,6 +907,17 @@

stone-well

View example
+
+ + wooden-barrel — A procedural staved barrel through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

wooden-barrel

+

A procedural staved barrel through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 4312 tris, two materials with 88 metal hoop faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.719×0.719×0.880 m, LOD ratios in band, convex collider 372 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/wooden-barrel/index.html b/docs/gallery/wooden-barrel/index.html new file mode 100644 index 0000000..760bc56 --- /dev/null +++ b/docs/gallery/wooden-barrel/index.html @@ -0,0 +1,1009 @@ + + + + + + wooden-barrel — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

wooden-barrel

+

A procedural staved barrel through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Recomputed: 4312 tris, two materials with 88 metal hoop faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.719×0.719×0.880 m, LOD ratios in band, convex collider 372 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/wooden-barrel/wooden_barrel.py --
+ +
+
+

A showcase piece, not an example. Procedural staved barrel (bulged staves, four iron hoops, top and bottom heads) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

+

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

+

Budgets

+

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 4200–4450 | 4312 / 4312 / 4312 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 2 distinct, ≥16 metal hoop faces | 2 slots, 88 hoop faces | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.719, 0.719, 0.880) m ± 0.01 | (0.7194, 0.7194, 0.8800), zmin 0 | | Collider tris | ≤ 400 | 372 | | Export | written, size > 0 | 327564 / 327564 / 327556 bytes |

+

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

Run

+
blender --background --python wooden_barrel.py --
+blender --background --python wooden_barrel.py -- --skip-decimate
+blender --background --python wooden_barrel.py -- --output barrel.png
+

Smoke does not pass --output or --skip-decimate.

+

Exit codes

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or hoop faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+
+
+

Source

+
+ showcase/wooden-barrel/wooden_barrel.py + View on GitHub → +
+
"""Game-ready wooden barrel — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural staved barrel after composing
+shipped pipeline pieces: bmesh construction, UVs, two materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python wooden_barrel.py --
+    blender --background --python wooden_barrel.py -- --skip-decimate
+    blender --background --python wooden_barrel.py -- --output barrel.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+HEIGHT = 0.88
+R_END = 0.27
+R_MID = 0.36
+N_STAVES = 12
+STAVE_THICK = 0.030
+N_RINGS = 5
+STAVE_GAP = 0.05
+HOOP_ZS = (0.10, 0.28, 0.60, 0.78)
+HOOP_H = 0.042
+HOOP_PAD = 0.014
+LID_T = 0.030
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.719, 0.719, 0.880)
+
+BASE_TRIS_MIN = 4200
+BASE_TRIS_MAX = 4450
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 400
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.05
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def radius_at(z):
+    return R_END + (R_MID - R_END) * math.sin(math.pi * z / HEIGHT)
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = list(geo["verts"])
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    bm.faces.ensure_lookup_table()
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_barrel_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    stave_verts = []
+    try:
+        zs = [HEIGHT * i / (N_RINGS - 1) for i in range(N_RINGS)]
+        for i in range(N_STAVES):
+            a0 = 2.0 * math.pi * i / N_STAVES
+            a1 = 2.0 * math.pi * (i + 1.0 - STAVE_GAP) / N_STAVES
+            outer = []
+            inner = []
+            for z in zs:
+                r = radius_at(z)
+                ov = (
+                    bm.verts.new((r * math.cos(a0), r * math.sin(a0), z)),
+                    bm.verts.new((r * math.cos(a1), r * math.sin(a1), z)),
+                )
+                ri = r - STAVE_THICK
+                iv = (
+                    bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z)),
+                    bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z)),
+                )
+                outer.append(ov)
+                inner.append(iv)
+                stave_verts.extend(ov)
+                stave_verts.extend(iv)
+            for k in range(N_RINGS - 1):
+                o0a, o0b = outer[k]
+                o1a, o1b = outer[k + 1]
+                i0a, i0b = inner[k]
+                i1a, i1b = inner[k + 1]
+                for vs in (
+                    (o0a, o1a, o1b, o0b),
+                    (i0b, i1b, i1a, i0a),
+                    (o0a, i0a, i1a, o1a),
+                    (o0b, o1b, i1b, i0b),
+                ):
+                    face = bm.faces.new(vs)
+                    face.material_index = WOOD_IDX
+            top = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0]))
+            top.material_index = WOOD_IDX
+            bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1]))
+            bot.material_index = WOOD_IDX
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in stave_verts for e in v.link_edges})
+            bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+
+        add_cylinder(bm, (0.0, 0.0, LID_T / 2.0), R_END - 0.012, LID_T, 16, WOOD_IDX)
+        add_cylinder(
+            bm, (0.0, 0.0, HEIGHT - LID_T / 2.0), R_END - 0.012, LID_T, 16, WOOD_IDX,
+        )
+        hoop_faces = set()
+        for z in HOOP_ZS:
+            before = set(bm.faces)
+            add_cylinder(
+                bm,
+                (0.0, 0.0, z),
+                radius_at(z) + HOOP_PAD,
+                HOOP_H,
+                20,
+                METAL_IDX,
+            )
+            hoop_faces.update(set(bm.faces) - before)
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(35.0):
+                    edge.smooth = False
+        for f in hoop_faces:
+            if f.is_valid:
+                f.material_index = METAL_IDX
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    # Do not materials.clear() — that resets polygon material_index to 0
+    # on this Blender, which would drop hoop faces onto wood.
+    mats = obj.data.materials
+    if len(mats) == 0:
+        mats.append(wood)
+        mats.append(metal)
+        return
+    mats[0] = wood
+    if len(mats) == 1:
+        mats.append(metal)
+    else:
+        mats[1] = metal
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("BarrelNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    # Duplicated from snippets/export_preset_unity.py (not a package).
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_barrel_mesh("BarrelLow", bevel_offset=0.006, bevel_segments=2)
+    high = build_barrel_mesh("BarrelHigh", bevel_offset=0.006, bevel_segments=4)
+    wood = principled("BarrelWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50)
+    metal = principled("BarrelMetal", (0.62, 0.60, 0.56, 1.0), 1.0, 0.22)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("barrel mesh did not build", 3), None, None, None, None, None
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    print(f"measured mat_index_counts={idx_counts}")
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x = bb[3] - bb[0]
+    size_y = bb[4] - bb[1]
+    size_z = bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, wood)
+    if img is None:
+        return fail("barrel has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BarrelLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BarrelLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider_src = build_barrel_mesh("BarrelColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "BarrelCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_wooden_barrel_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(
+        f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}"
+    )
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < 16:
+        return fail(
+            f"metal hoop faces {idx_counts.get(METAL_IDX, 0)} < 16",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-32.0)
+    low.rotation_euler.x = math.radians(4.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 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)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.68, -2.38, 1.38)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, HEIGHT / 2.0 + 0.02)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    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)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("wooden-barrel OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        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/showcase/gallery.json b/showcase/gallery.json index fc6e21f..eadb323 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -28,6 +28,18 @@ "mesh", "export" ] + }, + { + "name": "wooden-barrel", + "dir": "showcase/wooden-barrel", + "teaches": "A procedural staved barrel through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 4312 tris, two materials with 88 metal hoop faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.719×0.719×0.880 m, LOD ratios in band, convex collider 372 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/wooden-barrel-hero.webp", + "preview": "showcase/wooden-barrel/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/wooden-barrel/README.md b/showcase/wooden-barrel/README.md new file mode 100644 index 0000000..2a3534f --- /dev/null +++ b/showcase/wooden-barrel/README.md @@ -0,0 +1,73 @@ +# Wooden barrel + +A showcase piece, not an example. Procedural staved barrel (bulged staves, +four iron hoops, top and bottom heads) then the shipped pipeline: +unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex +collider, Unity glTF export. + +It asserts **budget conformance** of the generated result. It does not +witness an API contract. "It rendered without error" is not a check. + +**Composes** skills `mesh-editing-and-bmesh`, `bake-high-to-low`, +`depsgraph-and-evaluated-data`, `engine-export-presets`, and snippets +`bake_normal_high_to_low.py`, `setup_bake_target_image.py`, +`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`, +`export_preset_unity.py` (helpers copied, not imported as a package). + +## Budgets + +Declared as named constants; every gate **recomputes** from the mesh, +materials, UVs, evaluated LOD, collider, or export file. + +| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | +| --- | --- | --- | +| Base triangles | 4200–4450 | 4312 / 4312 / 4312 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | +| Materials | exactly 2 distinct, ≥16 metal hoop faces | 2 slots, 88 hoop faces | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.719, 0.719, 0.880) m ± 0.01 | (0.7194, 0.7194, 0.8800), zmin 0 | +| Collider tris | ≤ 400 | 372 | +| Export | written, size > 0 | 327564 / 327564 / 327556 bytes | + +DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across +series — the gate is a ratio band, not an exact count. This mesh happened +to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate +is `has_data` plus operator `FINISHED`, not byte-identity. Construction +uses no RNG. glTF byte size differs by a few bytes across series. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python wooden_barrel.py -- +blender --background --python wooden_barrel.py -- --skip-decimate +blender --background --python wooden_barrel.py -- --output barrel.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots, or hoop faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/wooden-barrel/preview.webp b/showcase/wooden-barrel/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..2f70c8edbb03e8a6de72ddffc364ff44ea67e908 GIT binary patch literal 16856 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 X0sWAYob3Pr00000000}Epa1{>itMbt literal 0 HcmV?d00001 diff --git a/showcase/wooden-barrel/wooden_barrel.py b/showcase/wooden-barrel/wooden_barrel.py new file mode 100644 index 0000000..6974703 --- /dev/null +++ b/showcase/wooden-barrel/wooden_barrel.py @@ -0,0 +1,685 @@ +"""Game-ready wooden barrel — a showcase piece, not an example. + +Asserts budget conformance of a procedural staved barrel after composing +shipped pipeline pieces: bmesh construction, UVs, two materials, high-to-low +normal bake, LOD chain, convex collider, Unity glTF export. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. ``--skip-decimate`` skips the LOD +DECIMATE stage so the LOD-ratio budget fails. + +No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +are not byte-identical across Blender versions — the LOD gate is a +ratio band, not an exact count. + + blender --background --python wooden_barrel.py -- + blender --background --python wooden_barrel.py -- --skip-decimate + blender --background --python wooden_barrel.py -- --output barrel.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +# Showcase lives at repo-root/showcase/, not under examples/. The framing +# helper is the repo's only shared import and lives next to the examples; +# resolve the repo root so we do not move gallery_framing.py. +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +HEIGHT = 0.88 +R_END = 0.27 +R_MID = 0.36 +N_STAVES = 12 +STAVE_THICK = 0.030 +N_RINGS = 5 +STAVE_GAP = 0.05 +HOOP_ZS = (0.10, 0.28, 0.60, 0.78) +HOOP_H = 0.042 +HOOP_PAD = 0.014 +LID_T = 0.030 +BBOX_TOL = 0.01 +OUTER_SIZE = (0.719, 0.719, 0.880) + +BASE_TRIS_MIN = 4200 +BASE_TRIS_MAX = 4450 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 2 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 400 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.05 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def radius_at(z): + return R_END + (R_MID - R_END) * math.sin(math.pi * z / HEIGHT) + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = list(geo["verts"]) + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + bm.faces.ensure_lookup_table() + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_barrel_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + stave_verts = [] + try: + zs = [HEIGHT * i / (N_RINGS - 1) for i in range(N_RINGS)] + for i in range(N_STAVES): + a0 = 2.0 * math.pi * i / N_STAVES + a1 = 2.0 * math.pi * (i + 1.0 - STAVE_GAP) / N_STAVES + outer = [] + inner = [] + for z in zs: + r = radius_at(z) + ov = ( + bm.verts.new((r * math.cos(a0), r * math.sin(a0), z)), + bm.verts.new((r * math.cos(a1), r * math.sin(a1), z)), + ) + ri = r - STAVE_THICK + iv = ( + bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z)), + bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z)), + ) + outer.append(ov) + inner.append(iv) + stave_verts.extend(ov) + stave_verts.extend(iv) + for k in range(N_RINGS - 1): + o0a, o0b = outer[k] + o1a, o1b = outer[k + 1] + i0a, i0b = inner[k] + i1a, i1b = inner[k + 1] + for vs in ( + (o0a, o1a, o1b, o0b), + (i0b, i1b, i1a, i0a), + (o0a, i0a, i1a, o1a), + (o0b, o1b, i1b, i0b), + ): + face = bm.faces.new(vs) + face.material_index = WOOD_IDX + top = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0])) + top.material_index = WOOD_IDX + bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1])) + bot.material_index = WOOD_IDX + + if bevel_offset > 0.0: + edges = list({e for v in stave_verts for e in v.link_edges}) + bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + add_cylinder(bm, (0.0, 0.0, LID_T / 2.0), R_END - 0.012, LID_T, 16, WOOD_IDX) + add_cylinder( + bm, (0.0, 0.0, HEIGHT - LID_T / 2.0), R_END - 0.012, LID_T, 16, WOOD_IDX, + ) + hoop_faces = set() + for z in HOOP_ZS: + before = set(bm.faces) + add_cylinder( + bm, + (0.0, 0.0, z), + radius_at(z) + HOOP_PAD, + HOOP_H, + 20, + METAL_IDX, + ) + hoop_faces.update(set(bm.faces) - before) + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(35.0): + edge.smooth = False + for f in hoop_faces: + if f.is_valid: + f.material_index = METAL_IDX + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal): + # Do not materials.clear() — that resets polygon material_index to 0 + # on this Blender, which would drop hoop faces onto wood. + mats = obj.data.materials + if len(mats) == 0: + mats.append(wood) + mats.append(metal) + return + mats[0] = wood + if len(mats) == 1: + mats.append(metal) + else: + mats[1] = metal + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("BarrelNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + # Duplicated from snippets/bake_normal_high_to_low.py (not a package). + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + # Duplicated from snippets/export_preset_unity.py (not a package). + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_barrel_mesh("BarrelLow", bevel_offset=0.006, bevel_segments=2) + high = build_barrel_mesh("BarrelHigh", bevel_offset=0.006, bevel_segments=4) + wood = principled("BarrelWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) + metal = principled("BarrelMetal", (0.62, 0.60, 0.56, 1.0), 1.0, 0.22) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("barrel mesh did not build", 3), None, None, None, None, None + + base_tris = triangle_count(low.data) + mats = [s for s in low.data.materials if s is not None] + nmat = len(mats) + distinct_mats = len({id(s) for s in mats}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + print(f"measured mat_index_counts={idx_counts}") + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x = bb[3] - bb[0] + size_y = bb[4] - bb[1] + size_z = bb[5] - bb[2] + + img, tex = setup_bake_image(low, wood) + if img is None: + return fail("barrel has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "BarrelLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BarrelLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + collider_src = build_barrel_mesh("BarrelColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "BarrelCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_wooden_barrel_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print( + f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" + ) + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < 16: + return fail( + f"metal hoop faces {idx_counts.get(METAL_IDX, 0)} < 16", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-32.0) + low.rotation_euler.x = math.radians(4.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 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) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.68, -2.38, 1.38) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, HEIGHT / 2.0 + 0.02) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + 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) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("wooden-barrel OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 0b31c93..bc9124b 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -75,5 +75,6 @@ {"name": "eval-mesh-datablock-name", "script": "examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py"}, {"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py"}, {"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"}, - {"name": "stone-well", "script": "showcase/stone-well/stone_well.py"} + {"name": "stone-well", "script": "showcase/stone-well/stone_well.py"}, + {"name": "wooden-barrel", "script": "showcase/wooden-barrel/wooden_barrel.py"} ]